From 5e6a836b879962dbe5110c810bca80176855f8b5 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 28 Oct 2019 11:22:17 +0100 Subject: [PATCH] Changes in Security plugin. --- .../plugins/security/common/login_state.ts | 1 - x-pack/legacy/plugins/security/index.d.ts | 2 - x-pack/legacy/plugins/security/index.js | 129 +- .../basic_login_form.test.tsx | 1 - .../__snapshots__/login_page.test.tsx.snap | 1 - .../components/login_page/login_page.test.tsx | 1 - .../components/edit_role_page.test.tsx | 12 +- .../views/management/edit_role/index.js | 2 +- .../server/lib/route_pre_check_license.js | 4 +- .../server/routes/api/v1/api_keys/index.js | 9 +- .../server/routes/api/v1/authenticate.js | 2 +- .../security/server/routes/api/v1/users.js | 2 +- .../server/routes/views/logged_out.js | 2 +- .../security/server/routes/views/login.js | 9 +- x-pack/plugins/security/common/constants.ts | 1 - x-pack/plugins/security/kibana.json | 1 + .../server/audit/audit_logger.test.ts | 15 +- .../security/server/audit/audit_logger.ts | 27 +- .../security/server/audit/index.mock.ts | 16 + x-pack/plugins/security/server/audit/index.ts | 7 + .../server/authentication/api_keys.test.ts | 24 +- .../server/authentication/api_keys.ts | 13 +- .../server/authentication/index.test.ts | 44 +- .../security/server/authentication/index.ts | 26 +- .../authorization/actions/actions.test.ts | 33 +- .../server/authorization/actions/actions.ts | 17 +- .../server/authorization/actions/index.ts | 2 +- .../server/authorization/actions/ui.test.ts | 4 +- .../server/authorization/actions/ui.ts | 6 +- .../authorization/api_authorization.test.ts | 286 +- .../server/authorization/api_authorization.ts | 32 +- .../authorization/app_authorization.test.ts | 309 +- .../server/authorization/app_authorization.ts | 56 +- .../authorization/check_privileges.test.ts | 55 +- .../server/authorization/check_privileges.ts | 36 +- .../check_privileges_dynamically.test.ts | 31 +- .../check_privileges_dynamically.ts | 28 +- .../check_saved_objects_privileges.test.ts | 35 +- .../check_saved_objects_privileges.ts | 25 +- .../disable_ui_capabilities.test.ts | 296 +- .../authorization/disable_ui_capabilities.ts | 37 +- .../server/authorization/index.mock.ts | 22 + .../server/authorization/index.test.ts | 101 + .../security/server/authorization/index.ts | 136 +- .../server/authorization/mode.test.ts | 63 +- .../security/server/authorization/mode.ts | 28 +- .../feature_privilege_builder/api.ts | 2 +- .../feature_privilege_builder/app.ts | 2 +- .../feature_privilege_builder/catalogue.ts | 2 +- .../feature_privilege_builder.ts | 2 +- .../feature_privilege_builder/index.ts | 2 +- .../feature_privilege_builder/management.ts | 2 +- .../feature_privilege_builder/navlink.ts | 2 +- .../feature_privilege_builder/saved_object.ts | 2 +- .../feature_privilege_builder/ui.ts | 2 +- .../privileges/privileges.test.ts | 9 +- .../authorization/privileges/privileges.ts | 10 +- .../authorization/privileges_serializer.ts | 2 +- .../register_privileges_with_cluster.test.ts | 499 ++- .../register_privileges_with_cluster.ts | 67 +- .../authorization/service.test.mocks.ts | 10 - .../server/authorization/service.test.ts | 112 - .../security/server/authorization/service.ts | 75 - .../validate_feature_privileges.test.ts | 15 +- .../validate_feature_privileges.ts | 4 +- .../security/server/licensing/index.mock.ts | 14 + .../security/server/licensing/index.ts | 7 + .../server/licensing/license_features.ts | 50 + .../server/licensing/license_service.test.ts | 117 +- .../server/licensing/license_service.ts | 125 +- x-pack/plugins/security/server/mocks.ts | 28 + x-pack/plugins/security/server/plugin.test.ts | 71 +- x-pack/plugins/security/server/plugin.ts | 142 +- .../routes/authentication/index.test.ts | 40 + .../server/routes/authentication/index.ts | 14 + .../server/routes/authentication/saml.test.ts | 42 +- .../server/routes/authentication/saml.ts | 12 +- .../server/routes/authorization/index.test.ts | 41 + .../server/routes/authorization/index.ts | 14 + .../authorization/privileges/get.test.ts | 109 +- .../routes/authorization/privileges/get.ts | 73 +- .../authorization/privileges/get_builtin.ts | 26 +- .../routes/authorization/privileges/index.ts | 12 +- .../routes/authorization/roles/delete.test.ts | 150 +- .../routes/authorization/roles/delete.ts | 49 +- .../routes/authorization/roles/get.test.ts | 3474 +++++++++-------- .../server/routes/authorization/roles/get.ts | 367 +- .../routes/authorization/roles/index.ts | 33 +- .../routes/authorization/roles/put.test.ts | 1005 +++-- .../server/routes/authorization/roles/put.ts | 332 +- .../security/server/routes/index.mock.ts | 27 + .../plugins/security/server/routes/index.ts | 12 +- .../server/routes/licensed_route_handler.ts | 32 + .../security/server/saved_objects/index.ts | 64 + ...ecure_saved_objects_client_wrapper.test.ts | 1383 +++---- .../secure_saved_objects_client_wrapper.ts | 211 +- .../apis/security/builtin_es_privileges.ts | 4 +- 97 files changed, 5539 insertions(+), 5351 deletions(-) create mode 100644 x-pack/plugins/security/server/audit/index.mock.ts create mode 100644 x-pack/plugins/security/server/audit/index.ts create mode 100644 x-pack/plugins/security/server/authorization/index.mock.ts create mode 100644 x-pack/plugins/security/server/authorization/index.test.ts delete mode 100644 x-pack/plugins/security/server/authorization/service.test.ts delete mode 100644 x-pack/plugins/security/server/authorization/service.ts create mode 100644 x-pack/plugins/security/server/licensing/index.mock.ts create mode 100644 x-pack/plugins/security/server/licensing/index.ts create mode 100644 x-pack/plugins/security/server/licensing/license_features.ts create mode 100644 x-pack/plugins/security/server/mocks.ts create mode 100644 x-pack/plugins/security/server/routes/authentication/index.test.ts create mode 100644 x-pack/plugins/security/server/routes/authentication/index.ts create mode 100644 x-pack/plugins/security/server/routes/authorization/index.test.ts create mode 100644 x-pack/plugins/security/server/routes/authorization/index.ts create mode 100644 x-pack/plugins/security/server/routes/index.mock.ts create mode 100644 x-pack/plugins/security/server/routes/licensed_route_handler.ts create mode 100644 x-pack/plugins/security/server/saved_objects/index.ts diff --git a/x-pack/legacy/plugins/security/common/login_state.ts b/x-pack/legacy/plugins/security/common/login_state.ts index b41fb85214c66b4..b1eb3d61fe5f309 100644 --- a/x-pack/legacy/plugins/security/common/login_state.ts +++ b/x-pack/legacy/plugins/security/common/login_state.ts @@ -9,5 +9,4 @@ export type LoginLayout = 'form' | 'error-es-unavailable' | 'error-xpack-unavail export interface LoginState { layout: LoginLayout; allowLogin: boolean; - loginMessage: string; } diff --git a/x-pack/legacy/plugins/security/index.d.ts b/x-pack/legacy/plugins/security/index.d.ts index a0d18dd3cbb99ce..18284c8be689a1d 100644 --- a/x-pack/legacy/plugins/security/index.d.ts +++ b/x-pack/legacy/plugins/security/index.d.ts @@ -6,12 +6,10 @@ import { Legacy } from 'kibana'; import { AuthenticatedUser } from './common/model'; -import { AuthorizationService } from './server/lib/authorization/service'; /** * Public interface of the security plugin. */ export interface SecurityPlugin { - authorization: Readonly; getUser: (request: Legacy.Request) => Promise; } diff --git a/x-pack/legacy/plugins/security/index.js b/x-pack/legacy/plugins/security/index.js index f9e82f575ce2e26..c098e3e67a6d91b 100644 --- a/x-pack/legacy/plugins/security/index.js +++ b/x-pack/legacy/plugins/security/index.js @@ -8,29 +8,13 @@ import { resolve } from 'path'; import { initAuthenticateApi } from './server/routes/api/v1/authenticate'; import { initUsersApi } from './server/routes/api/v1/users'; import { initApiKeysApi } from './server/routes/api/v1/api_keys'; -import { initExternalRolesApi } from './server/routes/api/external/roles'; -import { initPrivilegesApi } from './server/routes/api/external/privileges'; import { initIndicesApi } from './server/routes/api/v1/indices'; -import { initGetBuiltinPrivilegesApi } from './server/routes/api/v1/builtin_privileges'; import { initOverwrittenSessionView } from './server/routes/views/overwritten_session'; import { initLoginView } from './server/routes/views/login'; import { initLogoutView } from './server/routes/views/logout'; import { initLoggedOutView } from './server/routes/views/logged_out'; -import { checkLicense } from './server/lib/check_license'; -import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; -import { - createAuthorizationService, - disableUICapabilitesFactory, - initAPIAuthorization, - initAppAuthorization, - registerPrivilegesWithCluster, - validateFeaturePrivileges -} from './server/lib/authorization'; import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; -import { SecureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/secure_saved_objects_client_wrapper'; -import { deepFreeze } from './server/lib/deep_freeze'; -import { createOptionalPlugin } from '../../server/lib/optional_plugin'; import { KibanaRequest } from '../../../../src/core/server'; import { createCSPRuleString } from '../../../../src/legacy/server/csp'; @@ -103,23 +87,22 @@ export const security = (kibana) => new kibana.Plugin({ } return { - secureCookies: securityPlugin.config.secureCookies, - sessionTimeout: securityPlugin.config.sessionTimeout, + secureCookies: securityPlugin.__legacyCompat.config.secureCookies, + sessionTimeout: securityPlugin.__legacyCompat.config.sessionTimeout, enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), }; }, }, async postInit(server) { - const plugin = this; - - const xpackMainPlugin = server.plugins.xpack_main; + const securityPlugin = server.newPlatform.setup.plugins.security; + if (!securityPlugin) { + throw new Error('New Platform XPack Security plugin is not available.'); + } - watchStatusAndLicenseToInitialize(xpackMainPlugin, plugin, async (license) => { - if (license.allowRbac) { - const { security } = server.plugins; - await validateFeaturePrivileges(security.authorization.actions, xpackMainPlugin.getFeatures()); - await registerPrivilegesWithCluster(server); + watchStatusAndLicenseToInitialize(server.plugins.xpack_main, this, async () => { + if (securityPlugin.__legacyCompat.license.getFeatures().allowRbac) { + await securityPlugin.__legacyCompat.registerPrivilegesWithCluster(); } }); }, @@ -131,110 +114,46 @@ export const security = (kibana) => new kibana.Plugin({ } const config = server.config(); - const xpackMainPlugin = server.plugins.xpack_main; - const xpackInfo = xpackMainPlugin.info; - securityPlugin.registerLegacyAPI({ - xpackInfo, + const xpackInfo = server.plugins.xpack_main.info; + securityPlugin.__legacyCompat.registerLegacyAPI({ + savedObjects: server.savedObjects, + auditLogger: new AuditLogger(server, 'security', config, xpackInfo), isSystemAPIRequest: server.plugins.kibana.systemApi.isSystemApiRequest.bind( server.plugins.kibana.systemApi ), + capabilities: { registerCapabilitiesModifier: server.registerCapabilitiesModifier }, cspRules: createCSPRuleString(config.get('csp.rules')), + kibanaIndexName: config.get('kibana.index'), }); - const plugin = this; - const xpackInfoFeature = xpackInfo.feature(plugin.id); - - // Register a function that is called whenever the xpack info changes, - // to re-compute the license check results for this plugin - xpackInfoFeature.registerLicenseCheckResultsGenerator(checkLicense); + // Legacy xPack Info endpoint returns whatever we return in a callback for `registerLicenseCheckResultsGenerator` + // and the result is consumed by the legacy plugins all over the place, so we should keep it here for now. We assume + // that when legacy callback is called license has been already propagated to the new platform security plugin and + // features are up to date. + xpackInfo.feature(this.id).registerLicenseCheckResultsGenerator( + () => securityPlugin.__legacyCompat.license.getFeatures() + ); server.expose({ getUser: request => securityPlugin.authc.getCurrentUser(KibanaRequest.from(request)) }); - const { savedObjects } = server; - - const spaces = createOptionalPlugin(config, 'xpack.spaces', server.plugins, 'spaces'); - - // exposes server.plugins.security.authorization - const authorization = createAuthorizationService(server, xpackInfoFeature, xpackMainPlugin, spaces); - server.expose('authorization', deepFreeze(authorization)); - - const auditLogger = new SecurityAuditLogger(new AuditLogger(server, 'security', server.config(), xpackInfo)); - - savedObjects.setScopedSavedObjectsClientFactory(({ - request, - }) => { - const adminCluster = server.plugins.elasticsearch.getCluster('admin'); - const { callWithRequest, callWithInternalUser } = adminCluster; - const callCluster = (...args) => callWithRequest(request, ...args); - - if (authorization.mode.useRbacForRequest(request)) { - const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser); - return new savedObjects.SavedObjectsClient(internalRepository); - } - - const callWithRequestRepository = savedObjects.getSavedObjectsRepository(callCluster); - return new savedObjects.SavedObjectsClient(callWithRequestRepository); - }); - - savedObjects.addScopedSavedObjectsClientWrapperFactory(Number.MAX_SAFE_INTEGER - 1, 'security', ({ client, request }) => { - if (authorization.mode.useRbacForRequest(request)) { - return new SecureSavedObjectsClientWrapper({ - actions: authorization.actions, - auditLogger, - baseClient: client, - checkSavedObjectsPrivilegesWithRequest: authorization.checkSavedObjectsPrivilegesWithRequest, - errors: savedObjects.SavedObjectsClient.errors, - request, - savedObjectTypes: savedObjects.types, - }); - } - - return client; - }); - initAuthenticateApi(securityPlugin, server); - initAPIAuthorization(server, authorization); - initAppAuthorization(server, xpackMainPlugin, authorization); initUsersApi(securityPlugin, server); initApiKeysApi(server); - initExternalRolesApi(server); initIndicesApi(server); - initPrivilegesApi(server); - initGetBuiltinPrivilegesApi(server); - initLoginView(securityPlugin, server, xpackMainPlugin); + initLoginView(securityPlugin, server); initLogoutView(server); initLoggedOutView(securityPlugin, server); initOverwrittenSessionView(server); server.injectUiAppVars('login', () => { - - const { showLogin, loginMessage, allowLogin, layout = 'form' } = xpackInfo.feature(plugin.id).getLicenseCheckResults() || {}; - + const { showLogin, allowLogin, layout = 'form' } = securityPlugin.__legacyCompat.license.getFeatures(); return { loginState: { showLogin, allowLogin, - loginMessage, layout, } }; }); - - server.registerCapabilitiesModifier((request, uiCapabilities) => { - // if we have a license which doesn't enable security, or we're a legacy user - // we shouldn't disable any ui capabilities - const { authorization } = server.plugins.security; - if (!authorization.mode.useRbacForRequest(request)) { - return uiCapabilities; - } - - const disableUICapabilites = disableUICapabilitesFactory(server, request); - // if we're an anonymous route, we disable all ui capabilities - if (request.route.settings.auth === false) { - return disableUICapabilites.all(uiCapabilities); - } - - return disableUICapabilites.usingPrivileges(uiCapabilities); - }); } }); diff --git a/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx b/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx index 21c1dacb06d4224..664c9f2a046c099 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx +++ b/x-pack/legacy/plugins/security/public/views/login/components/basic_login_form/basic_login_form.test.tsx @@ -33,7 +33,6 @@ const createLoginState = (options?: Partial) => { return { allowLogin: true, layout: 'form', - loginMessage: '', ...options, } as LoginState; }; diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap b/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap index 852cbb26a1dcfbe..fc33c6e0a82cc2e 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/legacy/plugins/security/public/views/login/components/login_page/__snapshots__/login_page.test.tsx.snap @@ -389,7 +389,6 @@ exports[`LoginPage enabled form state renders as expected 1`] = ` Object { "allowLogin": true, "layout": "form", - "loginMessage": "", } } next="" diff --git a/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx b/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx index 8d7bd0e10352a45..af91d12624c644a 100644 --- a/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx +++ b/x-pack/legacy/plugins/security/public/views/login/components/login_page/login_page.test.tsx @@ -32,7 +32,6 @@ const createLoginState = (options?: Partial) => { return { allowLogin: true, layout: 'form', - loginMessage: '', ...options, } as LoginState; }; diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_role/components/edit_role_page.test.tsx b/x-pack/legacy/plugins/security/public/views/management/edit_role/components/edit_role_page.test.tsx index 75f9520cef64b76..cb60b773f92e0e2 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_role/components/edit_role_page.test.tsx +++ b/x-pack/legacy/plugins/security/public/views/management/edit_role/components/edit_role_page.test.tsx @@ -10,9 +10,9 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { UICapabilities } from 'ui/capabilities'; import { Space } from '../../../../../../spaces/common/model/space'; import { Feature } from '../../../../../../../../plugins/features/server'; +import { Actions } from '../../../../../../../../plugins/security/server/authorization/actions'; +import { privilegesFactory } from '../../../../../../../../plugins/security/server/authorization/privileges'; import { RawKibanaPrivileges, Role } from '../../../../../common/model'; -import { actionsFactory } from '../../../../../server/lib/authorization/actions'; -import { privilegesFactory } from '../../../../../server/lib/authorization/privileges'; import { EditRolePage } from './edit_role_page'; import { SimplePrivilegeSection } from './privileges/kibana/simple_privilege_section'; import { SpaceAwarePrivilegeSection } from './privileges/kibana/space_aware_privilege_section'; @@ -56,13 +56,9 @@ const buildFeatures = () => { }; const buildRawKibanaPrivileges = () => { - const xpackMainPlugin = { + return privilegesFactory(new Actions('unit_test_version'), { getFeatures: () => buildFeatures(), - }; - - const actions = actionsFactory({ get: jest.fn(() => 'unit_test_version') }); - - return privilegesFactory(actions, xpackMainPlugin as any).get(); + }).get(); }; const buildBuiltinESPrivileges = () => { diff --git a/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js b/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js index b1cf7e9f4675616..6de3b3f56c060b2 100644 --- a/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js +++ b/x-pack/legacy/plugins/security/public/views/management/edit_role/index.js @@ -88,7 +88,7 @@ const routeDefinition = (action) => ({ return kfetch({ method: 'get', pathname: '/api/security/privileges', query: { includeActions: true } }); }, builtinESPrivileges() { - return kfetch({ method: 'get', pathname: '/api/security/v1/esPrivileges/builtin' }); + return kfetch({ method: 'get', pathname: '/api/security/esPrivileges/builtin' }); }, features() { return kfetch({ method: 'get', pathname: '/api/features' }).catch(e => { diff --git a/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js b/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js index 41db792b33d943f..64816bf4d23d70c 100644 --- a/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js +++ b/x-pack/legacy/plugins/security/server/lib/route_pre_check_license.js @@ -7,10 +7,8 @@ const Boom = require('boom'); export function routePreCheckLicense(server) { - const xpackMainPlugin = server.plugins.xpack_main; - const pluginId = 'security'; return function forbidApiAccess() { - const licenseCheckResults = xpackMainPlugin.info.feature(pluginId).getLicenseCheckResults(); + const licenseCheckResults = server.newPlatform.setup.plugins.security.__legacyCompat.license.getFeatures(); if (!licenseCheckResults.showLinks) { throw Boom.forbidden(licenseCheckResults.linksMessage); } else { diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js index ade1f0974096c57..fc55bdcc386616f 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/api_keys/index.js @@ -14,10 +14,7 @@ export function initApiKeysApi(server) { const callWithRequest = getClient(server).callWithRequest; const routePreCheckLicenseFn = routePreCheckLicense(server); - const { authorization } = server.plugins.security; - const { application } = authorization; - - initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn, application); - initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn, application); - initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn, application); + initCheckPrivilegesApi(server, callWithRequest, routePreCheckLicenseFn); + initGetApiKeysApi(server, callWithRequest, routePreCheckLicenseFn); + initInvalidateApiKeysApi(server, callWithRequest, routePreCheckLicenseFn); } diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js index d22cf0aef4db735..f37c9a2fd917f12 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/authenticate.js @@ -11,7 +11,7 @@ import { canRedirectRequest, wrapError, OIDCAuthenticationFlow } from '../../../ import { KibanaRequest } from '../../../../../../../../src/core/server'; import { createCSPRuleString } from '../../../../../../../../src/legacy/server/csp'; -export function initAuthenticateApi({ authc: { login, logout }, config }, server) { +export function initAuthenticateApi({ authc: { login, logout }, __legacyCompat: { config } }, server) { function prepareCustomResourceResponse(response, contentType) { return response .header('cache-control', 'private, no-cache, no-store') diff --git a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js index 1d47dc88753486c..595182653fa2317 100644 --- a/x-pack/legacy/plugins/security/server/routes/api/v1/users.js +++ b/x-pack/legacy/plugins/security/server/routes/api/v1/users.js @@ -13,7 +13,7 @@ import { routePreCheckLicense } from '../../../lib/route_pre_check_license'; import { wrapError } from '../../../../../../../plugins/security/server'; import { KibanaRequest } from '../../../../../../../../src/core/server'; -export function initUsersApi({ authc: { login }, config }, server) { +export function initUsersApi({ authc: { login }, __legacyCompat: { config } }, server) { const callWithRequest = getClient(server).callWithRequest; const routePreCheckLicenseFn = routePreCheckLicense(server); diff --git a/x-pack/legacy/plugins/security/server/routes/views/logged_out.js b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js index 51867631b57bee7..25905aaab6f3fcf 100644 --- a/x-pack/legacy/plugins/security/server/routes/views/logged_out.js +++ b/x-pack/legacy/plugins/security/server/routes/views/logged_out.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export function initLoggedOutView({ config: { cookieName } }, server) { +export function initLoggedOutView({ __legacyCompat: { config: { cookieName } } }, server) { const config = server.config(); const loggedOut = server.getHiddenUiAppById('logged_out'); diff --git a/x-pack/legacy/plugins/security/server/routes/views/login.js b/x-pack/legacy/plugins/security/server/routes/views/login.js index f7e7f2933efcc6a..7e2b50b40f72768 100644 --- a/x-pack/legacy/plugins/security/server/routes/views/login.js +++ b/x-pack/legacy/plugins/security/server/routes/views/login.js @@ -8,16 +8,13 @@ import { get } from 'lodash'; import { parseNext } from '../../lib/parse_next'; -export function initLoginView({ config: { cookieName } }, server, xpackMainPlugin) { +export function initLoginView({ __legacyCompat: { config: { cookieName }, license } }, server) { const config = server.config(); const login = server.getHiddenUiAppById('login'); function shouldShowLogin() { - if (xpackMainPlugin && xpackMainPlugin.info) { - const licenseCheckResults = xpackMainPlugin.info.feature('security').getLicenseCheckResults(); - if (licenseCheckResults) { - return Boolean(licenseCheckResults.showLogin); - } + if (license.isEnabled()) { + return Boolean(license.getFeatures().showLogin); } // default to true if xpack info isn't available or diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index 2a255ecd335e502..44b6601daa7ff91 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -5,6 +5,5 @@ */ export const GLOBAL_RESOURCE = '*'; -export const IGNORED_TYPES = ['space']; export const APPLICATION_PREFIX = 'kibana-'; export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 9f243a7dfb2fc19..32f860b1423d300 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -3,6 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "security"], + "requiredPlugins": ["features", "licensing"], "server": true, "ui": true } diff --git a/x-pack/plugins/security/server/audit/audit_logger.test.ts b/x-pack/plugins/security/server/audit/audit_logger.test.ts index 716946adab41cbc..2ae8b6762c5d4ca 100644 --- a/x-pack/plugins/security/server/audit/audit_logger.test.ts +++ b/x-pack/plugins/security/server/audit/audit_logger.test.ts @@ -7,22 +7,21 @@ import { SecurityAuditLogger } from './audit_logger'; const createMockAuditLogger = () => { return { - log: jest.fn() + log: jest.fn(), }; }; describe(`#savedObjectsAuthorizationFailure`, () => { - test('logs via auditLogger', () => { const auditLogger = createMockAuditLogger(); const securityAuditLogger = new SecurityAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; - const types = [ 'foo-type-1', 'foo-type-2' ]; + const types = ['foo-type-1', 'foo-type-2']; const missing = [`saved_object:${types[0]}/foo-action`, `saved_object:${types[1]}/foo-action`]; const args = { - 'foo': 'bar', - 'baz': 'quz', + foo: 'bar', + baz: 'quz', }; securityAuditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); @@ -47,10 +46,10 @@ describe(`#savedObjectsAuthorizationSuccess`, () => { const securityAuditLogger = new SecurityAuditLogger(auditLogger); const username = 'foo-user'; const action = 'foo-action'; - const types = [ 'foo-type-1', 'foo-type-2' ]; + const types = ['foo-type-1', 'foo-type-2']; const args = { - 'foo': 'bar', - 'baz': 'quz', + foo: 'bar', + baz: 'quz', }; securityAuditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); diff --git a/x-pack/plugins/security/server/audit/audit_logger.ts b/x-pack/plugins/security/server/audit/audit_logger.ts index 1326aaf3ee4b541..4c2c57d0e029e7a 100644 --- a/x-pack/plugins/security/server/audit/audit_logger.ts +++ b/x-pack/plugins/security/server/audit/audit_logger.ts @@ -4,13 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LegacyAPI } from '../plugin'; + export class SecurityAuditLogger { - constructor(auditLogger) { - this._auditLogger = auditLogger; - } + constructor(private readonly auditLogger: LegacyAPI['auditLogger']) {} - savedObjectsAuthorizationFailure(username, action, types, missing, args) { - this._auditLogger.log( + savedObjectsAuthorizationFailure( + username: string, + action: string, + types: string[], + missing: string[], + args?: Record + ) { + this.auditLogger.log( 'saved_objects_authorization_failure', `${username} unauthorized to ${action} ${types.join(',')}, missing ${missing.join(',')}`, { @@ -18,13 +24,18 @@ export class SecurityAuditLogger { action, types, missing, - args + args, } ); } - savedObjectsAuthorizationSuccess(username, action, types, args) { - this._auditLogger.log( + savedObjectsAuthorizationSuccess( + username: string, + action: string, + types: string[], + args?: Record + ) { + this.auditLogger.log( 'saved_objects_authorization_success', `${username} authorized to ${action} ${types.join(',')}`, { diff --git a/x-pack/plugins/security/server/audit/index.mock.ts b/x-pack/plugins/security/server/audit/index.mock.ts new file mode 100644 index 000000000000000..c14b98ed4781eb5 --- /dev/null +++ b/x-pack/plugins/security/server/audit/index.mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SecurityAuditLogger } from './audit_logger'; + +export const securityAuditLoggerMock = { + create() { + return ({ + savedObjectsAuthorizationFailure: jest.fn(), + savedObjectsAuthorizationSuccess: jest.fn(), + } as unknown) as jest.Mocked; + }, +}; diff --git a/x-pack/plugins/security/server/audit/index.ts b/x-pack/plugins/security/server/audit/index.ts new file mode 100644 index 000000000000000..3ab253151b805d2 --- /dev/null +++ b/x-pack/plugins/security/server/audit/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SecurityAuditLogger } from './audit_logger'; diff --git a/x-pack/plugins/security/server/authentication/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys.test.ts index 7ecff1682465cf9..3fca1007413d4c4 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.test.ts @@ -4,19 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { APIKeys } from './api_keys'; import { IClusterClient, IScopedClusterClient } from '../../../../../src/core/server'; +import { SecurityLicense } from '../licensing'; +import { APIKeys } from './api_keys'; + import { httpServerMock, loggingServiceMock, elasticsearchServiceMock, } from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../licensing/index.mock'; describe('API Keys', () => { let apiKeys: APIKeys; let mockClusterClient: jest.Mocked; let mockScopedClusterClient: jest.Mocked; - const mockIsSecurityFeatureDisabled = jest.fn(); + let mockLicense: jest.Mocked; beforeEach(() => { mockClusterClient = elasticsearchServiceMock.createClusterClient(); @@ -24,17 +27,20 @@ describe('API Keys', () => { mockClusterClient.asScoped.mockReturnValue((mockScopedClusterClient as unknown) as jest.Mocked< IScopedClusterClient >); - mockIsSecurityFeatureDisabled.mockReturnValue(false); + + mockLicense = licenseMock.create(); + mockLicense.isEnabled.mockReturnValue(true); + apiKeys = new APIKeys({ clusterClient: mockClusterClient, logger: loggingServiceMock.create().get('api-keys'), - isSecurityFeatureDisabled: mockIsSecurityFeatureDisabled, + license: mockLicense, }); }); describe('create()', () => { it('returns null when security feature is disabled', async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(true); + mockLicense.isEnabled.mockReturnValue(false); const result = await apiKeys.create(httpServerMock.createKibanaRequest(), { name: '', role_descriptors: {}, @@ -44,7 +50,7 @@ describe('API Keys', () => { }); it('calls callCluster with proper parameters', async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(false); + mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ id: '123', name: 'key-name', @@ -77,7 +83,7 @@ describe('API Keys', () => { describe('invalidate()', () => { it('returns null when security feature is disabled', async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(true); + mockLicense.isEnabled.mockReturnValue(false); const result = await apiKeys.invalidate(httpServerMock.createKibanaRequest(), { id: '123', }); @@ -86,7 +92,7 @@ describe('API Keys', () => { }); it('calls callCluster with proper parameters', async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(false); + mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], @@ -111,7 +117,7 @@ describe('API Keys', () => { }); it(`Only passes id as a parameter`, async () => { - mockIsSecurityFeatureDisabled.mockReturnValue(false); + mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.callAsCurrentUser.mockResolvedValueOnce({ invalidated_api_keys: ['api-key-id-1'], previously_invalidated_api_keys: [], diff --git a/x-pack/plugins/security/server/authentication/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys.ts index 3709e8e7195fe60..b207e227c56af07 100644 --- a/x-pack/plugins/security/server/authentication/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys.ts @@ -5,6 +5,7 @@ */ import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/server'; +import { SecurityLicense } from '../licensing'; /** * Represents the options to create an APIKey class instance that will be @@ -13,7 +14,7 @@ import { IClusterClient, KibanaRequest, Logger } from '../../../../../src/core/s export interface ConstructorOptions { logger: Logger; clusterClient: IClusterClient; - isSecurityFeatureDisabled: () => boolean; + license: SecurityLicense; } /** @@ -92,12 +93,12 @@ export interface InvalidateAPIKeyResult { export class APIKeys { private readonly logger: Logger; private readonly clusterClient: IClusterClient; - private readonly isSecurityFeatureDisabled: () => boolean; + private readonly license: SecurityLicense; - constructor({ logger, clusterClient, isSecurityFeatureDisabled }: ConstructorOptions) { + constructor({ logger, clusterClient, license }: ConstructorOptions) { this.logger = logger; this.clusterClient = clusterClient; - this.isSecurityFeatureDisabled = isSecurityFeatureDisabled; + this.license = license; } /** @@ -109,7 +110,7 @@ export class APIKeys { request: KibanaRequest, params: CreateAPIKeyParams ): Promise { - if (this.isSecurityFeatureDisabled()) { + if (!this.license.isEnabled()) { return null; } @@ -139,7 +140,7 @@ export class APIKeys { request: KibanaRequest, params: InvalidateAPIKeyParams ): Promise { - if (this.isSecurityFeatureDisabled()) { + if (!this.license.isEnabled()) { return null; } diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 9342cce577dfb63..ff7cf876adbef23 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { licenseMock } from '../licensing/index.mock'; + jest.mock('./api_keys'); jest.mock('./authenticator'); @@ -41,32 +43,19 @@ import { InvalidateAPIKeyResult, InvalidateAPIKeyParams, } from './api_keys'; - -function mockXPackFeature({ isEnabled = true }: Partial<{ isEnabled: boolean }> = {}) { - return { - isEnabled: jest.fn().mockReturnValue(isEnabled), - isAvailable: jest.fn().mockReturnValue(true), - registerLicenseCheckResultsGenerator: jest.fn(), - getLicenseCheckResults: jest.fn(), - }; -} +import { SecurityLicense } from '../licensing'; describe('setupAuthentication()', () => { let mockSetupAuthenticationParams: { config: ConfigType; loggers: LoggerFactory; - getLegacyAPI(): LegacyAPI; - core: MockedKeys; + getLegacyAPI(): Pick; + http: jest.Mocked; clusterClient: jest.Mocked; + license: jest.Mocked; }; - let mockXpackInfo: jest.Mocked; let mockScopedClusterClient: jest.Mocked>; beforeEach(async () => { - mockXpackInfo = { - isAvailable: jest.fn().mockReturnValue(true), - feature: jest.fn().mockReturnValue(mockXPackFeature()), - }; - const mockConfig$ = createConfig$( coreMock.createPluginInitializerContext({ encryptionKey: 'ab'.repeat(16), @@ -77,11 +66,12 @@ describe('setupAuthentication()', () => { true ); mockSetupAuthenticationParams = { - core: coreMock.createSetup(), + http: coreMock.createSetup().http, config: await mockConfig$.pipe(first()).toPromise(), clusterClient: elasticsearchServiceMock.createClusterClient(), + license: licenseMock.create(), loggers: loggingServiceMock.create(), - getLegacyAPI: jest.fn().mockReturnValue({ xpackInfo: mockXpackInfo }), + getLegacyAPI: jest.fn(), }; mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -102,16 +92,16 @@ describe('setupAuthentication()', () => { await setupAuthentication(mockSetupAuthenticationParams); - expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledTimes(1); - expect(mockSetupAuthenticationParams.core.http.registerAuth).toHaveBeenCalledWith( + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledTimes(1); + expect(mockSetupAuthenticationParams.http.registerAuth).toHaveBeenCalledWith( expect.any(Function) ); expect( - mockSetupAuthenticationParams.core.http.createCookieSessionStorageFactory + mockSetupAuthenticationParams.http.createCookieSessionStorageFactory ).toHaveBeenCalledTimes(1); expect( - mockSetupAuthenticationParams.core.http.createCookieSessionStorageFactory + mockSetupAuthenticationParams.http.createCookieSessionStorageFactory ).toHaveBeenCalledWith({ encryptionKey: config.encryptionKey, isSecure: config.secureCookies, @@ -129,7 +119,7 @@ describe('setupAuthentication()', () => { await setupAuthentication(mockSetupAuthenticationParams); - authHandler = mockSetupAuthenticationParams.core.http.registerAuth.mock.calls[0][0]; + authHandler = mockSetupAuthenticationParams.http.registerAuth.mock.calls[0][0]; authenticate = jest.requireMock('./authenticator').Authenticator.mock.instances[0] .authenticate; }); @@ -138,7 +128,7 @@ describe('setupAuthentication()', () => { const mockRequest = httpServerMock.createKibanaRequest(); const mockResponse = httpServerMock.createLifecycleResponseFactory(); - mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); await authHandler(mockRequest, mockResponse, mockAuthToolkit); @@ -302,7 +292,7 @@ describe('setupAuthentication()', () => { }); it('returns `null` if Security is disabled', async () => { - mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); await expect(getCurrentUser(httpServerMock.createKibanaRequest())).resolves.toBe(null); }); @@ -331,7 +321,7 @@ describe('setupAuthentication()', () => { }); it('returns `true` if Security is disabled', async () => { - mockXpackInfo.feature.mockReturnValue(mockXPackFeature({ isEnabled: false })); + mockSetupAuthenticationParams.license.isEnabled.mockReturnValue(false); await expect(isAuthenticated(httpServerMock.createKibanaRequest())).resolves.toBe(true); }); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 9553ddd09b2c13b..df16dd375e858a5 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -16,6 +16,7 @@ import { getErrorStatusCode } from '../errors'; import { Authenticator, ProviderSession } from './authenticator'; import { LegacyAPI } from '../plugin'; import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys'; +import { SecurityLicense } from '../licensing'; export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; @@ -30,35 +31,32 @@ export { } from './api_keys'; interface SetupAuthenticationParams { - core: CoreSetup; + http: CoreSetup['http']; clusterClient: IClusterClient; config: ConfigType; + license: SecurityLicense; loggers: LoggerFactory; - getLegacyAPI(): LegacyAPI; + getLegacyAPI(): Pick; } export type Authentication = UnwrapPromise>; export async function setupAuthentication({ - core, + http, clusterClient, config, + license, loggers, getLegacyAPI, }: SetupAuthenticationParams) { const authLogger = loggers.get('authentication'); - const isSecurityFeatureDisabled = () => { - const xpackInfo = getLegacyAPI().xpackInfo; - return xpackInfo.isAvailable() && !xpackInfo.feature('security').isEnabled(); - }; - /** * Retrieves currently authenticated user associated with the specified request. * @param request */ const getCurrentUser = async (request: KibanaRequest) => { - if (isSecurityFeatureDisabled()) { + if (!license.isEnabled()) { return null; } @@ -69,11 +67,11 @@ export async function setupAuthentication({ const authenticator = new Authenticator({ clusterClient, - basePath: core.http.basePath, + basePath: http.basePath, config: { sessionTimeout: config.sessionTimeout, authc: config.authc }, isSystemAPIRequest: (request: KibanaRequest) => getLegacyAPI().isSystemAPIRequest(request), loggers, - sessionStorageFactory: await core.http.createCookieSessionStorageFactory({ + sessionStorageFactory: await http.createCookieSessionStorageFactory({ encryptionKey: config.encryptionKey, isSecure: config.secureCookies, name: config.cookieName, @@ -84,9 +82,9 @@ export async function setupAuthentication({ authLogger.debug('Successfully initialized authenticator.'); - core.http.registerAuth(async (request, response, t) => { + http.registerAuth(async (request, response, t) => { // If security is disabled continue with no user credentials and delete the client cookie as well. - if (isSecurityFeatureDisabled()) { + if (!license.isEnabled()) { return t.authenticated(); } @@ -148,7 +146,7 @@ export async function setupAuthentication({ const apiKeys = new APIKeys({ clusterClient, logger: loggers.get('api-key'), - isSecurityFeatureDisabled, + license, }); return { login: authenticator.login.bind(authenticator), diff --git a/x-pack/plugins/security/server/authorization/actions/actions.test.ts b/x-pack/plugins/security/server/authorization/actions/actions.test.ts index 11194d237e10c58..384d25ca3b97108 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.test.ts @@ -4,31 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { actionsFactory } from '.'; - -const createMockConfig = (settings: Record = {}) => { - const mockConfig = { - get: jest.fn(), - }; - - mockConfig.get.mockImplementation(key => settings[key]); - - return mockConfig; -}; +import { Actions } from '.'; describe('#constructor', () => { - test('requires version to be a string', () => { - const mockConfig = createMockConfig(); - - expect(() => actionsFactory(mockConfig)).toThrowErrorMatchingInlineSnapshot( - `"version should be a string"` - ); - }); - test(`doesn't allow an empty string`, () => { - const mockConfig = createMockConfig({ 'pkg.version': '' }); - - expect(() => actionsFactory(mockConfig)).toThrowErrorMatchingInlineSnapshot( + expect(() => new Actions('')).toThrowErrorMatchingInlineSnapshot( `"version can't be an empty string"` ); }); @@ -36,10 +16,7 @@ describe('#constructor', () => { describe('#login', () => { test('returns login:', () => { - const version = 'mock-version'; - const mockConfig = createMockConfig({ 'pkg.version': version }); - - const actions = actionsFactory(mockConfig); + const actions = new Actions('mock-version'); expect(actions.login).toBe('login:'); }); @@ -48,9 +25,7 @@ describe('#login', () => { describe('#version', () => { test("returns `version:${config.get('pkg.version')}`", () => { const version = 'mock-version'; - const mockConfig = createMockConfig({ 'pkg.version': version }); - - const actions = actionsFactory(mockConfig); + const actions = new Actions(version); expect(actions.version).toBe(`version:${version}`); }); diff --git a/x-pack/plugins/security/server/authorization/actions/actions.ts b/x-pack/plugins/security/server/authorization/actions/actions.ts index e10a0c9bc93131e..4bf7a41550cc654 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.ts @@ -36,18 +36,9 @@ export class Actions { public readonly version = `version:${this.versionNumber}`; - constructor(private readonly versionNumber: string) {} -} - -export function actionsFactory(config: any) { - const version = config.get('pkg.version'); - if (typeof version !== 'string') { - throw new Error('version should be a string'); + constructor(private readonly versionNumber: string) { + if (versionNumber === '') { + throw new Error(`version can't be an empty string`); + } } - - if (version === '') { - throw new Error(`version can't be an empty string`); - } - - return new Actions(version); } diff --git a/x-pack/plugins/security/server/authorization/actions/index.ts b/x-pack/plugins/security/server/authorization/actions/index.ts index 34af70cd479e3fa..d844ef5f4ae333d 100644 --- a/x-pack/plugins/security/server/authorization/actions/index.ts +++ b/x-pack/plugins/security/server/authorization/actions/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { Actions, actionsFactory } from './actions'; +export { Actions } from './actions'; diff --git a/x-pack/plugins/security/server/authorization/actions/ui.test.ts b/x-pack/plugins/security/server/authorization/actions/ui.test.ts index 7f486dc3a8c9838..f91b7baf78baadd 100644 --- a/x-pack/plugins/security/server/authorization/actions/ui.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.test.ts @@ -29,10 +29,10 @@ describe('#allCatalogueEntries', () => { }); }); -describe('#allManagmentLinks', () => { +describe('#allManagementLinks', () => { test('returns `ui:${version}:management/*`', () => { const uiActions = new UIActions(version); - expect(uiActions.allManagmentLinks).toBe('ui:1.0.0-zeta1:management/*'); + expect(uiActions.allManagementLinks).toBe('ui:1.0.0-zeta1:management/*'); }); }); diff --git a/x-pack/plugins/security/server/authorization/actions/ui.ts b/x-pack/plugins/security/server/authorization/actions/ui.ts index ec5af3496eae633..c243b4f0bbdc189 100644 --- a/x-pack/plugins/security/server/authorization/actions/ui.ts +++ b/x-pack/plugins/security/server/authorization/actions/ui.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { isString } from 'lodash'; -import { UICapabilities } from 'ui/capabilities'; -import { uiCapabilitiesRegex } from '../../../../../../../plugins/features/server'; +import { Capabilities as UICapabilities } from '../../../../../../src/core/public'; +import { uiCapabilitiesRegex } from '../../../../features/server'; export class UIActions { private readonly prefix: string; @@ -26,7 +26,7 @@ export class UIActions { return `${this.prefix}catalogue/*`; } - public get allManagmentLinks(): string { + public get allManagementLinks(): string { return `${this.prefix}management/*`; } diff --git a/x-pack/plugins/security/server/authorization/api_authorization.test.ts b/x-pack/plugins/security/server/authorization/api_authorization.test.ts index 00d920c2f15b2da..a5902f251b08293 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -4,190 +4,152 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Server } from 'hapi'; -import { AuthorizationService } from './service'; - -import { actionsFactory } from './actions'; import { initAPIAuthorization } from './api_authorization'; -const actions = actionsFactory({ - get(key: string) { - if (key === 'pkg.version') { - return `1.0.0-zeta1`; - } - - throw new Error(`Unexpected config key: ${key}`); - }, -}); +import { + coreMock, + httpServerMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../src/core/server/mocks'; +import { authorizationMock } from './index.mock'; describe('initAPIAuthorization', () => { test(`route that doesn't start with "/api/" continues`, async () => { - const server = new Server(); - initAPIAuthorization(server, {} as AuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/foo', - }); - expect(result).toBe('foo app response'); - expect(statusCode).toBe(200); + const mockHTTPSetup = coreMock.createSetup().http; + initAPIAuthorization( + mockHTTPSetup, + authorizationMock.create(), + loggingServiceMock.create().get() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ method: 'get', path: '/app/foo' }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); }); test(`protected route that starts with "/api/", but "mode.useRbacForRequest()" returns false continues`, async () => { - const server = new Server(); - const mockAuthorizationService: AuthorizationService = { - mode: { - useRbacForRequest: jest.fn().mockReturnValue(false), - }, - } as any; - initAPIAuthorization(server, mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - options: { - tags: ['access:foo'], - }, - handler: () => { - return 'foo api response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create(); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/api/foo', + routeTags: ['access:foo'], }); - expect(result).toBe('foo api response'); - expect(statusCode).toBe(200); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + mockAuthz.mode.useRbacForRequest.mockReturnValue(false); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); test(`unprotected route that starts with "/api/", but "mode.useRbacForRequest()" returns true continues`, async () => { - const server = new Server(); - const mockAuthorizationService: AuthorizationService = { - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAPIAuthorization(server, mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - options: { - tags: ['not-access:foo'], - }, - handler: () => { - return 'foo api response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create(); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/api/foo', + routeTags: ['not-access:foo'], }); - expect(result).toBe('foo api response'); - expect(statusCode).toBe(200); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); test(`protected route that starts with "/api/", "mode.useRbacForRequest()" returns true and user is authorized continues`, async () => { - const headers = { - authorization: 'foo', - }; - const server = new Server(); - const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: true }); - const mockAuthorizationService: AuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest: (req: any) => { - // hapi conceals the actual "request" from us, so we make sure that the headers are passed to - // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with - expect(req.headers).toMatchObject(headers); - - return mockCheckPrivileges; - }, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAPIAuthorization(server, mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - options: { - tags: ['access:foo'], - }, - handler: () => { - return 'foo api response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/api/foo', headers, + routeTags: ['access:foo'], }); - expect(result).toBe('foo api response'); - expect(statusCode).toBe(200); - expect(mockCheckPrivileges).toHaveBeenCalledWith([actions.api.get('foo')]); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: true }); + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + // hapi conceals the actual "request" from us, so we make sure that the headers are passed to + // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with + expect(request.headers).toMatchObject(headers); + + return mockCheckPrivileges; + }); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockAuthz.actions.api.get('foo')]); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); test(`protected route that starts with "/api/", "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { - const headers = { - authorization: 'foo', - }; - const server = new Server(); - const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: false }); - const mockAuthorizationService: AuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest: (req: any) => { - // hapi conceals the actual "request" from us, so we make sure that the headers are passed to - // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with - expect(req.headers).toMatchObject(headers); - - return mockCheckPrivileges; - }, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAPIAuthorization(server, mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - options: { - tags: ['access:foo'], - }, - handler: () => { - return 'foo api response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingServiceMock.create().get()); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/api/foo', headers, + routeTags: ['access:foo'], }); - expect(result).toMatchInlineSnapshot(` -Object { - "error": "Not Found", - "message": "Not Found", - "statusCode": 404, -} -`); - expect(statusCode).toBe(404); - expect(mockCheckPrivileges).toHaveBeenCalledWith([actions.api.get('foo')]); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: false }); + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + // hapi conceals the actual "request" from us, so we make sure that the headers are passed to + // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with + expect(request.headers).toMatchObject(headers); + + return mockCheckPrivileges; + }); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); + expect(mockCheckPrivileges).toHaveBeenCalledWith([mockAuthz.actions.api.get('foo')]); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); }); diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index 57dd9a4802a5acc..b280cc74c230f57 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -4,26 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { Request, ResponseToolkit, Server } from 'hapi'; -import { AuthorizationService } from './service'; - -export function initAPIAuthorization(server: Server, authorization: AuthorizationService) { - const { actions, checkPrivilegesDynamicallyWithRequest, mode } = authorization; - - server.ext('onPostAuth', async (request: Request, h: ResponseToolkit) => { +import { CoreSetup, Logger } from '../../../../../src/core/server'; +import { Authorization } from '.'; + +export function initAPIAuthorization( + http: CoreSetup['http'], + { actions, checkPrivilegesDynamicallyWithRequest, mode }: Authorization, + logger: Logger +) { + http.registerOnPostAuth(async (request, response, toolkit) => { // if the api doesn't start with "/api/" or we aren't using RBAC for this request, just continue - if (!request.path.startsWith('/api/') || !mode.useRbacForRequest(request)) { - return h.continue; + if (!request.url.path!.startsWith('/api/') || !mode.useRbacForRequest(request)) { + return toolkit.next(); } - const { tags = [] } = request.route.settings; + const tags = request.route.options.tags; const tagPrefix = 'access:'; const actionTags = tags.filter(tag => tag.startsWith(tagPrefix)); // if there are no tags starting with "access:", just continue if (actionTags.length === 0) { - return h.continue; + logger.debug('API endpoint is not marked with "access:" tags, skipping.'); + return toolkit.next(); } const apiActions = actionTags.map(tag => actions.api.get(tag.substring(tagPrefix.length))); @@ -32,9 +34,11 @@ export function initAPIAuthorization(server: Server, authorization: Authorizatio // we've actually authorized the request if (checkPrivilegesResponse.hasAllRequested) { - return h.continue; + logger.debug(`authorized for "${request.url.path}"`); + return toolkit.next(); } - return Boom.notFound(); + logger.debug(`not authorized for "${request.url.path}"`); + return response.notFound(); }); } diff --git a/x-pack/plugins/security/server/authorization/app_authorization.test.ts b/x-pack/plugins/security/server/authorization/app_authorization.test.ts index 52bc6de63146a2a..6d2333302230259 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.test.ts @@ -4,195 +4,172 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Server } from 'hapi'; -import { AuthorizationService } from './service'; - -import { Feature } from '../../../../../../plugins/features/server'; -import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { actionsFactory } from './actions'; +import { PluginSetupContract as FeaturesSetupContract } from '../../../features/server'; import { initAppAuthorization } from './app_authorization'; -const actions = actionsFactory({ - get(key: string) { - if (key === 'pkg.version') { - return `1.0.0-zeta1`; - } - - throw new Error(`Unexpected config key: ${key}`); - }, -}); +import { + loggingServiceMock, + coreMock, + httpServerMock, + httpServiceMock, +} from '../../../../../src/core/server/mocks'; +import { authorizationMock } from './index.mock'; -const createMockXPackMainPlugin = (): XPackMainPlugin => { - const features: Feature[] = [ - { - id: 'foo', - name: 'Foo', - app: ['foo'], - privileges: {}, - }, - ]; +const createFeaturesSetupContractMock = (): FeaturesSetupContract => { return { - getFeatures: () => features, - } as XPackMainPlugin; + getFeatures: () => [{ id: 'foo', name: 'Foo', app: ['foo'], privileges: {} }], + } as FeaturesSetupContract; }; describe('initAppAuthorization', () => { test(`route that doesn't start with "/app/" continues`, async () => { - const server = new Server(); - initAppAuthorization(server, createMockXPackMainPlugin(), {} as AuthorizationService); - server.route([ - { - method: 'GET', - path: '/api/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { result, statusCode } = await server.inject({ - method: 'GET', - url: '/api/foo', - }); - expect(result).toBe('foo app response'); - expect(statusCode).toBe(200); + const mockHTTPSetup = coreMock.createSetup().http; + initAppAuthorization( + mockHTTPSetup, + authorizationMock.create(), + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ method: 'get', path: '/api/foo' }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); }); test(`protected route that starts with "/app/", but "mode.useRbacForRequest()" returns false continues`, async () => { - const server = new Server(); - const mockAuthorizationService: AuthorizationService = { - mode: { - useRbacForRequest: jest.fn().mockReturnValue(false), - }, - } as any; - initAppAuthorization(server, createMockXPackMainPlugin(), mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/foo', - }); - expect(result).toBe('foo app response'); - expect(statusCode).toBe(200); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create(); + initAppAuthorization( + mockHTTPSetup, + mockAuthz, + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ method: 'get', path: '/app/foo' }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + mockAuthz.mode.useRbacForRequest.mockReturnValue(false); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); test(`unprotected route that starts with "/app/", and "mode.useRbacForRequest()" returns true continues`, async () => { - const server = new Server(); - const mockAuthorizationService: AuthorizationService = { - actions, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAppAuthorization(server, createMockXPackMainPlugin(), mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/bar', - handler: () => { - return 'bar app response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/bar', - }); - expect(result).toBe('bar app response'); - expect(statusCode).toBe(200); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create(); + initAppAuthorization( + mockHTTPSetup, + mockAuthz, + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const mockRequest = httpServerMock.createKibanaRequest({ method: 'get', path: '/app/bar' }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); test(`protected route that starts with "/app/", "mode.useRbacForRequest()" returns true and user is authorized continues`, async () => { - const headers = { - authorization: 'foo', - }; - const server = new Server(); - const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: true }); - const mockAuthorizationService: AuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest: (req: any) => { - // hapi conceals the actual "request" from us, so we make sure that the headers are passed to - // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with - expect(req.headers).toMatchObject(headers); - - return mockCheckPrivileges; - }, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAppAuthorization(server, createMockXPackMainPlugin(), mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/foo', + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + + initAppAuthorization( + mockHTTPSetup, + mockAuthz, + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/app/foo', headers, }); - expect(result).toBe('foo app response'); - expect(statusCode).toBe(200); - expect(mockCheckPrivileges).toHaveBeenCalledWith(actions.app.get('foo')); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: true }); + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + // hapi conceals the actual "request" from us, so we make sure that the headers are passed to + // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with + expect(request.headers).toMatchObject(headers); + + return mockCheckPrivileges; + }); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).not.toHaveBeenCalled(); + expect(mockPostAuthToolkit.next).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges).toHaveBeenCalledWith(mockAuthz.actions.app.get('foo')); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); test(`protected route that starts with "/app/", "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 404`, async () => { - const headers = { - authorization: 'foo', - }; - const server = new Server(); - const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: false }); - const mockAuthorizationService: AuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest: (req: any) => { - // hapi conceals the actual "request" from us, so we make sure that the headers are passed to - // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with - expect(req.headers).toMatchObject(headers); - - return mockCheckPrivileges; - }, - mode: { - useRbacForRequest: jest.fn().mockReturnValue(true), - }, - } as any; - initAppAuthorization(server, createMockXPackMainPlugin(), mockAuthorizationService); - server.route([ - { - method: 'GET', - path: '/app/foo', - handler: () => { - return 'foo app response'; - }, - }, - ]); - const { request, result, statusCode } = await server.inject({ - method: 'GET', - url: '/app/foo', + const mockHTTPSetup = coreMock.createSetup().http; + const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + + initAppAuthorization( + mockHTTPSetup, + mockAuthz, + loggingServiceMock.create().get(), + createFeaturesSetupContractMock() + ); + + const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: '/app/foo', headers, }); - expect(result).toMatchInlineSnapshot(` -Object { - "error": "Not Found", - "message": "Not Found", - "statusCode": 404, -} -`); - expect(statusCode).toBe(404); - expect(mockCheckPrivileges).toHaveBeenCalledWith(actions.app.get('foo')); - expect(mockAuthorizationService.mode.useRbacForRequest).toHaveBeenCalledWith(request); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); + + const mockCheckPrivileges = jest.fn().mockReturnValue({ hasAllRequested: false }); + mockAuthz.mode.useRbacForRequest.mockReturnValue(true); + mockAuthz.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + // hapi conceals the actual "request" from us, so we make sure that the headers are passed to + // "checkPrivilegesDynamicallyWithRequest" because this is what we're really concerned with + expect(request.headers).toMatchObject(headers); + + return mockCheckPrivileges; + }); + + await postAuthHandler(mockRequest, mockResponse, mockPostAuthToolkit); + + expect(mockResponse.notFound).toHaveBeenCalledTimes(1); + expect(mockPostAuthToolkit.next).not.toHaveBeenCalled(); + expect(mockCheckPrivileges).toHaveBeenCalledWith(mockAuthz.actions.app.get('foo')); + expect(mockAuthz.mode.useRbacForRequest).toHaveBeenCalledWith(mockRequest); }); }); diff --git a/x-pack/plugins/security/server/authorization/app_authorization.ts b/x-pack/plugins/security/server/authorization/app_authorization.ts index dd44050ec3e2ab9..8516e8228ab5add 100644 --- a/x-pack/plugins/security/server/authorization/app_authorization.ts +++ b/x-pack/plugins/security/server/authorization/app_authorization.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { Request, ResponseToolkit, Server } from 'hapi'; -import { flatten } from 'lodash'; -import { XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { AuthorizationService } from './service'; +import { CoreSetup, Logger } from '../../../../../src/core/server'; +import { FeaturesService } from '../plugin'; +import { Authorization } from '.'; + class ProtectedApplications { private applications: Set | null = null; - constructor(private readonly xpackMainPlugin: XPackMainPlugin) {} + constructor(private readonly featuresService: FeaturesService) {} public shouldProtect(appId: string) { // Currently, once we get the list of features we essentially "lock" additional - // features from being added. This is enforced by the xpackMain plugin. As such, + // features from being added. This is enforced by the Features plugin. As such, // we wait until we actually need to consume these before getting them if (this.applications == null) { this.applications = new Set( - flatten(this.xpackMainPlugin.getFeatures().map(feature => feature.app)) + this.featuresService + .getFeatures() + .map(feature => feature.app) + .flat() ); } @@ -28,45 +30,49 @@ class ProtectedApplications { } export function initAppAuthorization( - server: Server, - xpackMainPlugin: XPackMainPlugin, - authorization: AuthorizationService + http: CoreSetup['http'], + { + actions, + checkPrivilegesDynamicallyWithRequest, + mode, + }: Pick, + logger: Logger, + featuresService: FeaturesService ) { - const { actions, checkPrivilegesDynamicallyWithRequest, mode } = authorization; - const protectedApplications = new ProtectedApplications(xpackMainPlugin); - const log = (msg: string) => server.log(['security', 'app-authorization', 'debug'], msg); + const protectedApplications = new ProtectedApplications(featuresService); + + http.registerOnPostAuth(async (request, response, toolkit) => { + const path = request.url.pathname!; - server.ext('onPostAuth', async (request: Request, h: ResponseToolkit) => { - const { path } = request; // if the path doesn't start with "/app/", just continue if (!path.startsWith('/app/')) { - return h.continue; + return toolkit.next(); } // if we aren't using RBAC, just continue if (!mode.useRbacForRequest(request)) { - return h.continue; + return toolkit.next(); } const appId = path.split('/', 3)[2]; if (!protectedApplications.shouldProtect(appId)) { - log(`not authorizing - "${appId}" isn't a protected application`); - return h.continue; + logger.debug(`not authorizing - "${appId}" isn't a protected application`); + return toolkit.next(); } const checkPrivileges = checkPrivilegesDynamicallyWithRequest(request); const appAction = actions.app.get(appId); const checkPrivilegesResponse = await checkPrivileges(appAction); - log(`authorizing access to "${appId}"`); + logger.debug(`authorizing access to "${appId}"`); // we've actually authorized the request if (checkPrivilegesResponse.hasAllRequested) { - log(`authorized for "${appId}"`); - return h.continue; + logger.debug(`authorized for "${appId}"`); + return toolkit.next(); } - log(`not authorized for "${appId}"`); - return Boom.notFound(); + logger.debug(`not authorized for "${appId}"`); + return response.notFound(); }); } diff --git a/x-pack/plugins/security/server/authorization/check_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_privileges.test.ts index b418e02474f4abb..98e938f2507ab01 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.test.ts @@ -5,9 +5,11 @@ */ import { uniq } from 'lodash'; -import { GLOBAL_RESOURCE } from '../../../common/constants'; import { checkPrivilegesWithRequestFactory } from './check_privileges'; import { HasPrivilegesResponse } from './types'; +import { GLOBAL_RESOURCE } from '../../common/constants'; + +import { elasticsearchServiceMock, httpServerMock } from '../../../../../src/core/server/mocks'; const application = 'kibana-our_application'; @@ -18,14 +20,14 @@ const mockActions = { const savedObjectTypes = ['foo-type', 'bar-type']; -const createMockShieldClient = (response: any) => { - const mockCallWithRequest = jest.fn(); +const createMockClusterClient = (response: any) => { + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(response); - mockCallWithRequest.mockImplementationOnce(async () => response); + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.asScoped.mockReturnValue(mockScopedClusterClient); - return { - callWithRequest: mockCallWithRequest, - }; + return { mockClusterClient, mockScopedClusterClient }; }; describe('#atSpace', () => { @@ -40,13 +42,15 @@ describe('#atSpace', () => { } ) => { test(description, async () => { - const mockShieldClient = createMockShieldClient(options.esHasPrivilegesResponse); + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( mockActions, - application, - mockShieldClient + mockClusterClient, + () => application ); - const request = { foo: Symbol() }; + const request = httpServerMock.createKibanaRequest(); const checkPrivileges = checkPrivilegesWithRequest(request); let actualResult; @@ -60,8 +64,7 @@ describe('#atSpace', () => { errorThrown = err; } - expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith( - request, + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( 'shield.hasPrivileges', { body: { @@ -281,13 +284,15 @@ describe('#atSpaces', () => { } ) => { test(description, async () => { - const mockShieldClient = createMockShieldClient(options.esHasPrivilegesResponse); + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( mockActions, - application, - mockShieldClient + mockClusterClient, + () => application ); - const request = { foo: Symbol() }; + const request = httpServerMock.createKibanaRequest(); const checkPrivileges = checkPrivilegesWithRequest(request); let actualResult; @@ -301,8 +306,7 @@ describe('#atSpaces', () => { errorThrown = err; } - expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith( - request, + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( 'shield.hasPrivileges', { body: { @@ -760,13 +764,15 @@ describe('#globally', () => { } ) => { test(description, async () => { - const mockShieldClient = createMockShieldClient(options.esHasPrivilegesResponse); + const { mockClusterClient, mockScopedClusterClient } = createMockClusterClient( + options.esHasPrivilegesResponse + ); const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( mockActions, - application, - mockShieldClient + mockClusterClient, + () => application ); - const request = { foo: Symbol() }; + const request = httpServerMock.createKibanaRequest(); const checkPrivileges = checkPrivilegesWithRequest(request); let actualResult; @@ -777,8 +783,7 @@ describe('#globally', () => { errorThrown = err; } - expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith( - request, + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( 'shield.hasPrivileges', { body: { diff --git a/x-pack/plugins/security/server/authorization/check_privileges.ts b/x-pack/plugins/security/server/authorization/check_privileges.ts index a23f89a4bd7a537..9f66bfce8aca8d7 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges.ts @@ -5,10 +5,11 @@ */ import { pick, transform, uniq } from 'lodash'; -import { GLOBAL_RESOURCE } from '../../../common/constants'; +import { IClusterClient, KibanaRequest } from '../../../../../src/core/server'; import { ResourceSerializer } from './resource_serializer'; import { HasPrivilegesResponse, HasPrivilegesResponseApplication } from './types'; import { validateEsPrivilegeResponse } from './validate_es_response'; +import { GLOBAL_RESOURCE } from '../../common/constants'; interface CheckPrivilegesActions { login: string; @@ -43,7 +44,7 @@ export interface CheckPrivilegesAtSpacesResponse { }; } -export type CheckPrivilegesWithRequest = (request: Record) => CheckPrivileges; +export type CheckPrivilegesWithRequest = (request: KibanaRequest) => CheckPrivileges; export interface CheckPrivileges { atSpace( @@ -59,12 +60,10 @@ export interface CheckPrivileges { export function checkPrivilegesWithRequestFactory( actions: CheckPrivilegesActions, - application: string, - shieldClient: any + clusterClient: IClusterClient, + getApplicationName: () => string ) { - const { callWithRequest } = shieldClient; - - const hasIncompatibileVersion = ( + const hasIncompatibleVersion = ( applicationPrivilegesResponse: HasPrivilegesResponseApplication ) => { return Object.values(applicationPrivilegesResponse).some( @@ -72,7 +71,7 @@ export function checkPrivilegesWithRequestFactory( ); }; - return function checkPrivilegesWithRequest(request: Record): CheckPrivileges { + return function checkPrivilegesWithRequest(request: KibanaRequest): CheckPrivileges { const checkPrivilegesAtResources = async ( resources: string[], privilegeOrPrivileges: string | string[] @@ -82,21 +81,14 @@ export function checkPrivilegesWithRequestFactory( : [privilegeOrPrivileges]; const allApplicationPrivileges = uniq([actions.version, actions.login, ...privileges]); - const hasPrivilegesResponse: HasPrivilegesResponse = await callWithRequest( - request, - 'shield.hasPrivileges', - { + const application = getApplicationName(); + const hasPrivilegesResponse = (await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.hasPrivileges', { body: { - applications: [ - { - application, - resources, - privileges: allApplicationPrivileges, - }, - ], + applications: [{ application, resources, privileges: allApplicationPrivileges }], }, - } - ); + })) as HasPrivilegesResponse; validateEsPrivilegeResponse( hasPrivilegesResponse, @@ -107,7 +99,7 @@ export function checkPrivilegesWithRequestFactory( const applicationPrivilegesResponse = hasPrivilegesResponse.application[application]; - if (hasIncompatibileVersion(applicationPrivilegesResponse)) { + if (hasIncompatibleVersion(applicationPrivilegesResponse)) { throw new Error( 'Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.' ); diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts index 6df9d6801e2dc73..2206748597635b4 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.test.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacySpacesPlugin } from '../../../../spaces'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; +import { httpServerMock } from '../../../../../src/core/server/mocks'; + test(`checkPrivileges.atSpace when spaces is enabled`, async () => { const expectedResult = Symbol(); const spaceId = 'foo-space'; @@ -15,21 +15,15 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { atSpace: jest.fn().mockReturnValue(expectedResult), }; const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockSpaces = { - isEnabled: true, - getSpaceId: jest.fn().mockReturnValue(spaceId), - spaceIdToNamespace: jest.fn(), - namespaceToSpaceId: jest.fn(), - getBasePath: jest.fn(), - getScopedSpacesClient: jest.fn(), - getActiveSpace: jest.fn(), - } as OptionalPlugin; - const request = Symbol(); + const request = httpServerMock.createKibanaRequest(); const privilegeOrPrivileges = ['foo', 'bar']; const checkPrivilegesDynamically = checkPrivilegesDynamicallyWithRequestFactory( mockCheckPrivilegesWithRequest, - mockSpaces - )(request as any); + () => ({ + getSpaceId: jest.fn().mockReturnValue(spaceId), + namespaceToSpaceId: jest.fn(), + }) + )(request); const result = await checkPrivilegesDynamically(privilegeOrPrivileges); expect(result).toBe(expectedResult); @@ -43,15 +37,12 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { globally: jest.fn().mockReturnValue(expectedResult), }; const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockSpaces = { - isEnabled: false, - } as OptionalPlugin; - const request = Symbol(); + const request = httpServerMock.createKibanaRequest(); const privilegeOrPrivileges = ['foo', 'bar']; const checkPrivilegesDynamically = checkPrivilegesDynamicallyWithRequestFactory( mockCheckPrivilegesWithRequest, - mockSpaces - )(request as any); + () => undefined + )(request); const result = await checkPrivilegesDynamically(privilegeOrPrivileges); expect(result).toBe(expectedResult); diff --git a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts index 243ad100c5715ef..0377dd06eb66926 100644 --- a/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts +++ b/x-pack/plugins/security/server/authorization/check_privileges_dynamically.ts @@ -4,39 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; +import { KibanaRequest } from '../../../../../src/core/server'; +import { SpacesService } from '../plugin'; import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { LegacySpacesPlugin } from '../../../../spaces'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; - export type CheckPrivilegesDynamically = ( privilegeOrPrivileges: string | string[] ) => Promise; export type CheckPrivilegesDynamicallyWithRequest = ( - request: Legacy.Request + request: KibanaRequest ) => CheckPrivilegesDynamically; export function checkPrivilegesDynamicallyWithRequestFactory( checkPrivilegesWithRequest: CheckPrivilegesWithRequest, - spaces: OptionalPlugin + getSpacesService: () => SpacesService | undefined ): CheckPrivilegesDynamicallyWithRequest { - return function checkPrivilegesDynamicallyWithRequest(request: Legacy.Request) { + return function checkPrivilegesDynamicallyWithRequest(request: KibanaRequest) { const checkPrivileges = checkPrivilegesWithRequest(request); return async function checkPrivilegesDynamically(privilegeOrPrivileges: string | string[]) { - if (spaces.isEnabled) { - const spaceId = spaces.getSpaceId(request); - return await checkPrivileges.atSpace(spaceId, privilegeOrPrivileges); - } else { - return await checkPrivileges.globally(privilegeOrPrivileges); - } + const spacesService = getSpacesService(); + return spacesService + ? await checkPrivileges.atSpace(spacesService.getSpaceId(request), privilegeOrPrivileges) + : await checkPrivileges.globally(privilegeOrPrivileges); }; }; } diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index 7fa02330fac9794..4618e8e6641fcf8 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacySpacesPlugin } from '../../../../spaces'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; +import { httpServerMock } from '../../../../../src/core/server/mocks'; + test(`checkPrivileges.atSpace when spaces is enabled`, async () => { const expectedResult = Symbol(); const spaceId = 'foo-space'; @@ -15,19 +15,17 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { atSpace: jest.fn().mockReturnValue(expectedResult), }; const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - - const mockSpaces = ({ - isEnabled: true, - namespaceToSpaceId: jest.fn().mockReturnValue(spaceId), - } as unknown) as OptionalPlugin; - const request = Symbol(); - + const request = httpServerMock.createKibanaRequest(); const privilegeOrPrivileges = ['foo', 'bar']; + const mockSpacesService = { + getSpaceId: jest.fn(), + namespaceToSpaceId: jest.fn().mockReturnValue(spaceId), + }; const checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequestFactory( mockCheckPrivilegesWithRequest, - mockSpaces - )(request as any); + () => mockSpacesService + )(request); const namespace = 'foo'; @@ -36,7 +34,7 @@ test(`checkPrivileges.atSpace when spaces is enabled`, async () => { expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, privilegeOrPrivileges); - expect(mockSpaces.namespaceToSpaceId).toBeCalledWith(namespace); + expect(mockSpacesService.namespaceToSpaceId).toBeCalledWith(namespace); }); test(`checkPrivileges.globally when spaces is disabled`, async () => { @@ -45,21 +43,15 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { globally: jest.fn().mockReturnValue(expectedResult), }; const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockSpaces = ({ - isEnabled: false, - namespaceToSpaceId: jest.fn().mockImplementation(() => { - throw new Error('should not be called'); - }), - } as unknown) as OptionalPlugin; - const request = Symbol(); + const request = httpServerMock.createKibanaRequest(); const privilegeOrPrivileges = ['foo', 'bar']; const checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequestFactory( mockCheckPrivilegesWithRequest, - mockSpaces - )(request as any); + () => undefined + )(request); const namespace = 'foo'; @@ -68,5 +60,4 @@ test(`checkPrivileges.globally when spaces is disabled`, async () => { expect(result).toBe(expectedResult); expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivileges.globally).toHaveBeenCalledWith(privilegeOrPrivileges); - expect(mockSpaces.namespaceToSpaceId).not.toHaveBeenCalled(); }); diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index fb1d258b5a05fb6..02958fe265efac8 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -4,13 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; -import { LegacySpacesPlugin } from '../../../../spaces'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; +import { KibanaRequest } from '../../../../../src/core/server'; +import { SpacesService } from '../plugin'; import { CheckPrivilegesAtResourceResponse, CheckPrivilegesWithRequest } from './check_privileges'; export type CheckSavedObjectsPrivilegesWithRequest = ( - request: Legacy.Request + request: KibanaRequest ) => CheckSavedObjectsPrivileges; export type CheckSavedObjectsPrivileges = ( actions: string | string[], @@ -19,20 +18,20 @@ export type CheckSavedObjectsPrivileges = ( export const checkSavedObjectsPrivilegesWithRequestFactory = ( checkPrivilegesWithRequest: CheckPrivilegesWithRequest, - spaces: OptionalPlugin + getSpacesService: () => SpacesService | undefined ): CheckSavedObjectsPrivilegesWithRequest => { - return function checkSavedObjectsPrivilegesWithRequest(request: Legacy.Request) { + return function checkSavedObjectsPrivilegesWithRequest(request: KibanaRequest) { return async function checkSavedObjectsPrivileges( actions: string | string[], namespace?: string ) { - if (spaces.isEnabled) { - return checkPrivilegesWithRequest(request).atSpace( - spaces.namespaceToSpaceId(namespace), - actions - ); - } - return checkPrivilegesWithRequest(request).globally(actions); + const spacesService = getSpacesService(); + return spacesService + ? await checkPrivilegesWithRequest(request).atSpace( + spacesService.namespaceToSpaceId(namespace), + actions + ) + : await checkPrivilegesWithRequest(request).globally(actions); }; }; }; diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 198a36177c55acd..49c9db2d0e6e36d 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -5,79 +5,48 @@ */ import { Actions } from '.'; -import { Feature } from '../../../../../../plugins/features/server'; -import { disableUICapabilitesFactory } from './disable_ui_capabilities'; +import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; -interface MockServerOptions { - checkPrivileges: { - reject?: any; - resolve?: any; - }; - features: Feature[]; -} +import { httpServerMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; +import { authorizationMock } from './index.mock'; -const actions = new Actions('1.0.0-zeta1'); -const mockRequest = { - foo: Symbol(), -}; +type MockAuthzOptions = { rejectCheckPrivileges: any } | { resolveCheckPrivileges: any }; -const createMockServer = (options: MockServerOptions) => { - const mockAuthorizationService = { - actions, - checkPrivilegesDynamicallyWithRequest(request: any) { - expect(request).toBe(mockRequest); - - return jest.fn().mockImplementation(checkActions => { - if (options.checkPrivileges.reject) { - throw options.checkPrivileges.reject; - } - - if (options.checkPrivileges.resolve) { - expect(checkActions).toEqual(Object.keys(options.checkPrivileges.resolve.privileges)); - return options.checkPrivileges.resolve; - } +const actions = new Actions('1.0.0-zeta1'); +const mockRequest = httpServerMock.createKibanaRequest(); - throw new Error('resolve or reject should have been provided'); - }); - }, - }; +const createMockAuthz = (options: MockAuthzOptions) => { + const mock = authorizationMock.create({ version: '1.0.0-zeta1' }); + mock.checkPrivilegesDynamicallyWithRequest.mockImplementation(request => { + expect(request).toBe(mockRequest); - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(options.features), - }; + return jest.fn().mockImplementation(checkActions => { + if ('rejectCheckPrivileges' in options) { + throw options.rejectCheckPrivileges; + } - return { - log: jest.fn(), - plugins: { - security: { - authorization: mockAuthorizationService, - }, - xpack_main: mockXPackMainPlugin, - }, - }; + expect(checkActions).toEqual(Object.keys(options.resolveCheckPrivileges.privileges)); + return options.resolveCheckPrivileges; + }); + }); + return mock; }; describe('usingPrivileges', () => { describe('checkPrivileges errors', () => { test(`disables uiCapabilities when a 401 is thrown`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - reject: { - statusCode: 401, - message: 'super informative message', - }, - }, - features: [ - { - id: 'fooFeature', - name: 'Foo Feature', - app: [], - navLinkId: 'foo', - privileges: {}, - }, - ], + const mockAuthz = createMockAuthz({ + rejectCheckPrivileges: { statusCode: 401, message: 'super informative message' }, }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + const mockLoggers = loggingServiceMock.create(); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + mockLoggers.get(), + mockAuthz + ); + const result = await usingPrivileges( Object.freeze({ navLinks: { @@ -122,46 +91,28 @@ describe('usingPrivileges', () => { }, }); - expect(mockServer.log).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - "security", - "debug", - ], - "Disabling all uiCapabilities because we received a 401: super informative message", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], -} -`); + expect(loggingServiceMock.collect(mockLoggers).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "Disabling all uiCapabilities because we received a 401: super informative message", + ], + ] + `); }); test(`disables uiCapabilities when a 403 is thrown`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - reject: { - statusCode: 403, - message: 'even more super informative message', - }, - }, - features: [ - { - id: 'fooFeature', - name: 'Foo Feature', - navLinkId: 'foo', - app: [], - privileges: {}, - }, - ], + const mockAuthz = createMockAuthz({ + rejectCheckPrivileges: { statusCode: 403, message: 'even more super informative message' }, }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + const mockLoggers = loggingServiceMock.create(); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + mockLoggers.get(), + mockAuthz + ); + const result = await usingPrivileges( Object.freeze({ navLinks: { @@ -205,35 +156,28 @@ describe('usingPrivileges', () => { bar: false, }, }); - expect(mockServer.log).toMatchInlineSnapshot(` -[MockFunction] { - "calls": Array [ - Array [ - Array [ - "security", - "debug", - ], - "Disabling all uiCapabilities because we received a 403: even more super informative message", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": undefined, - }, - ], -} -`); + expect(loggingServiceMock.collect(mockLoggers).debug).toMatchInlineSnapshot(` + Array [ + Array [ + "Disabling all uiCapabilities because we received a 403: even more super informative message", + ], + ] + `); }); test(`otherwise it throws the error`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - reject: new Error('something else entirely'), - }, - features: [], + const mockAuthz = createMockAuthz({ + rejectCheckPrivileges: new Error('something else entirely'), }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + const mockLoggers = loggingServiceMock.create(); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [], + mockLoggers.get(), + mockAuthz + ); + await expect( usingPrivileges({ navLinks: { @@ -248,28 +192,40 @@ describe('usingPrivileges', () => { catalogue: {}, }) ).rejects.toThrowErrorMatchingSnapshot(); - expect(mockServer.log).not.toHaveBeenCalled(); + expect(loggingServiceMock.collect(mockLoggers)).toMatchInlineSnapshot(` + Object { + "debug": Array [], + "error": Array [], + "fatal": Array [], + "info": Array [], + "log": Array [], + "trace": Array [], + "warn": Array [], + } + `); }); }); test(`disables ui capabilities when they don't have privileges`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - resolve: { - privileges: { - [actions.ui.get('navLinks', 'foo')]: true, - [actions.ui.get('navLinks', 'bar')]: false, - [actions.ui.get('navLinks', 'quz')]: false, - [actions.ui.get('management', 'kibana', 'indices')]: true, - [actions.ui.get('management', 'kibana', 'settings')]: false, - [actions.ui.get('fooFeature', 'foo')]: true, - [actions.ui.get('fooFeature', 'bar')]: false, - [actions.ui.get('barFeature', 'foo')]: true, - [actions.ui.get('barFeature', 'bar')]: false, - }, + const mockAuthz = createMockAuthz({ + resolveCheckPrivileges: { + privileges: { + [actions.ui.get('navLinks', 'foo')]: true, + [actions.ui.get('navLinks', 'bar')]: false, + [actions.ui.get('navLinks', 'quz')]: false, + [actions.ui.get('management', 'kibana', 'indices')]: true, + [actions.ui.get('management', 'kibana', 'settings')]: false, + [actions.ui.get('fooFeature', 'foo')]: true, + [actions.ui.get('fooFeature', 'bar')]: false, + [actions.ui.get('barFeature', 'foo')]: true, + [actions.ui.get('barFeature', 'bar')]: false, }, }, - features: [ + }); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [ { id: 'fooFeature', name: 'Foo Feature', @@ -285,8 +241,10 @@ describe('usingPrivileges', () => { privileges: {}, }, ], - }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + loggingServiceMock.create().get(), + mockAuthz + ); + const result = await usingPrivileges( Object.freeze({ navLinks: { @@ -337,21 +295,23 @@ describe('usingPrivileges', () => { }); test(`doesn't re-enable disabled uiCapabilities`, async () => { - const mockServer = createMockServer({ - checkPrivileges: { - resolve: { - privileges: { - [actions.ui.get('navLinks', 'foo')]: true, - [actions.ui.get('navLinks', 'bar')]: true, - [actions.ui.get('management', 'kibana', 'indices')]: true, - [actions.ui.get('fooFeature', 'foo')]: true, - [actions.ui.get('fooFeature', 'bar')]: true, - [actions.ui.get('barFeature', 'foo')]: true, - [actions.ui.get('barFeature', 'bar')]: true, - }, + const mockAuthz = createMockAuthz({ + resolveCheckPrivileges: { + privileges: { + [actions.ui.get('navLinks', 'foo')]: true, + [actions.ui.get('navLinks', 'bar')]: true, + [actions.ui.get('management', 'kibana', 'indices')]: true, + [actions.ui.get('fooFeature', 'foo')]: true, + [actions.ui.get('fooFeature', 'bar')]: true, + [actions.ui.get('barFeature', 'foo')]: true, + [actions.ui.get('barFeature', 'bar')]: true, }, }, - features: [ + }); + + const { usingPrivileges } = disableUICapabilitiesFactory( + mockRequest, + [ { id: 'fooFeature', name: 'Foo Feature', @@ -367,8 +327,10 @@ describe('usingPrivileges', () => { privileges: {}, }, ], - }); - const { usingPrivileges } = disableUICapabilitesFactory(mockServer, mockRequest); + loggingServiceMock.create().get(), + mockAuthz + ); + const result = await usingPrivileges( Object.freeze({ navLinks: { @@ -417,21 +379,15 @@ describe('usingPrivileges', () => { describe('all', () => { test(`disables uiCapabilities`, () => { - const mockServer = createMockServer({ - checkPrivileges: { - reject: new Error(`Don't use me`), - }, - features: [ - { - id: 'fooFeature', - name: 'Foo Feature', - navLinkId: 'foo', - app: [], - privileges: {}, - }, - ], - }); - const { all } = disableUICapabilitesFactory(mockServer, mockRequest); + const mockAuthz = createMockAuthz({ rejectCheckPrivileges: new Error(`Don't use me`) }); + + const { all } = disableUICapabilitiesFactory( + mockRequest, + [{ id: 'fooFeature', name: 'Foo Feature', app: [], navLinkId: 'foo', privileges: {} }], + loggingServiceMock.create().get(), + mockAuthz + ); + const result = all( Object.freeze({ navLinks: { diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts index 4d952bca20a3daf..be26f52fbf75622 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.ts @@ -6,26 +6,22 @@ import { flatten, isObject, mapValues } from 'lodash'; import { UICapabilities } from 'ui/capabilities'; -import { Feature } from '../../../../../../plugins/features/server'; -import { Actions } from './actions'; +import { KibanaRequest, Logger } from '../../../../../src/core/server'; +import { Feature } from '../../../features/server'; + import { CheckPrivilegesAtResourceResponse } from './check_privileges'; -import { CheckPrivilegesDynamically } from './check_privileges_dynamically'; +import { Authorization } from './index'; -export function disableUICapabilitesFactory( - server: Record, - request: Record +export function disableUICapabilitiesFactory( + request: KibanaRequest, + features: Feature[], + logger: Logger, + authz: Authorization ) { - const { - security: { authorization }, - xpack_main: xpackMainPlugin, - } = server.plugins; - - const features: Feature[] = xpackMainPlugin.getFeatures(); const featureNavLinkIds = features .map(feature => feature.navLinkId) .filter(navLinkId => navLinkId != null); - const actions: Actions = authorization.actions; const shouldDisableFeatureUICapability = ( featureId: keyof UICapabilities, uiCapability: string @@ -61,10 +57,10 @@ export function disableUICapabilitesFactory( value: boolean | Record ): string[] { if (typeof value === 'boolean') { - return [actions.ui.get(featureId, uiCapability)]; + return [authz.actions.ui.get(featureId, uiCapability)]; } if (isObject(value)) { - return Object.keys(value).map(item => actions.ui.get(featureId, uiCapability, item)); + return Object.keys(value).map(item => authz.actions.ui.get(featureId, uiCapability, item)); } throw new Error(`Expected value type of boolean or object, but found ${value}`); } @@ -83,17 +79,14 @@ export function disableUICapabilitesFactory( let checkPrivilegesResponse: CheckPrivilegesAtResourceResponse; try { - const checkPrivilegesDynamically: CheckPrivilegesDynamically = authorization.checkPrivilegesDynamicallyWithRequest( - request - ); + const checkPrivilegesDynamically = authz.checkPrivilegesDynamicallyWithRequest(request); checkPrivilegesResponse = await checkPrivilegesDynamically(uiActions); } catch (err) { // if we get a 401/403, then we want to disable all uiCapabilities, as this // is generally when the user hasn't authenticated yet and we're displaying the // login screen, which isn't driven any uiCapabilities if (err.statusCode === 401 || err.statusCode === 403) { - server.log( - ['security', 'debug'], + logger.debug( `Disabling all uiCapabilities because we received a ${err.statusCode}: ${err.message}` ); return disableAll(uiCapabilities); @@ -107,11 +100,11 @@ export function disableUICapabilitesFactory( ...uiCapabilityParts: string[] ) => { // if the uiCapability has already been disabled, we don't want to re-enable it - if (enabled === false) { + if (!enabled) { return false; } - const action = actions.ui.get(featureId, ...uiCapabilityParts); + const action = authz.actions.ui.get(featureId, ...uiCapabilityParts); return checkPrivilegesResponse.privileges[action] === true; }; diff --git a/x-pack/plugins/security/server/authorization/index.mock.ts b/x-pack/plugins/security/server/authorization/index.mock.ts new file mode 100644 index 000000000000000..2e700745c69dcc8 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/index.mock.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Actions } from '.'; +import { AuthorizationMode } from './mode'; + +export const authorizationMock = { + create: ({ version = 'mock-version' }: { version?: string } = {}) => ({ + actions: new Actions(version), + checkPrivilegesWithRequest: jest.fn(), + checkPrivilegesDynamicallyWithRequest: jest.fn(), + checkSavedObjectsPrivilegesWithRequest: jest.fn(), + getApplicationName: jest.fn().mockReturnValue('mock-application'), + mode: { useRbacForRequest: jest.fn() } as jest.Mocked, + privileges: { get: jest.fn() }, + registerPrivilegesWithCluster: jest.fn(), + disableUnauthorizedCapabilities: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/authorization/index.test.ts b/x-pack/plugins/security/server/authorization/index.test.ts new file mode 100644 index 000000000000000..24179e062230a98 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/index.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + mockAuthorizationModeFactory, + mockCheckPrivilegesDynamicallyWithRequestFactory, + mockCheckPrivilegesWithRequestFactory, + mockCheckSavedObjectsPrivilegesWithRequestFactory, + mockPrivilegesFactory, +} from './service.test.mocks'; + +import { checkPrivilegesWithRequestFactory } from './check_privileges'; +import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; +import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; +import { authorizationModeFactory } from './mode'; +import { privilegesFactory } from './privileges'; +import { setupAuthorization } from '.'; + +import { + coreMock, + elasticsearchServiceMock, + loggingServiceMock, +} from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../licensing/index.mock'; + +test(`returns exposed services`, () => { + const kibanaIndexName = '.a-kibana-index'; + const application = `kibana-${kibanaIndexName}`; + + const mockCheckPrivilegesWithRequest = Symbol(); + mockCheckPrivilegesWithRequestFactory.mockReturnValue(mockCheckPrivilegesWithRequest); + + const mockCheckPrivilegesDynamicallyWithRequest = Symbol(); + mockCheckPrivilegesDynamicallyWithRequestFactory.mockReturnValue( + mockCheckPrivilegesDynamicallyWithRequest + ); + + const mockCheckSavedObjectsPrivilegesWithRequest = Symbol(); + mockCheckSavedObjectsPrivilegesWithRequestFactory.mockReturnValue( + mockCheckSavedObjectsPrivilegesWithRequest + ); + + const mockPrivilegesService = Symbol(); + mockPrivilegesFactory.mockReturnValue(mockPrivilegesService); + const mockAuthorizationMode = Symbol(); + mockAuthorizationModeFactory.mockReturnValue(mockAuthorizationMode); + + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + const mockGetSpacesService = jest + .fn() + .mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }); + const mockFeaturesService = { getFeatures: () => [] }; + const mockGetLegacyAPI = () => ({ kibanaIndexName }); + const mockLicense = licenseMock.create(); + + const authz = setupAuthorization({ + http: coreMock.createSetup().http, + clusterClient: mockClusterClient, + license: mockLicense, + loggers: loggingServiceMock.create(), + getLegacyAPI: mockGetLegacyAPI, + packageVersion: 'some-version', + featuresService: mockFeaturesService, + getSpacesService: mockGetSpacesService, + }); + + expect(authz.actions.version).toBe('version:some-version'); + expect(authz.getApplicationName()).toBe(application); + + expect(authz.checkPrivilegesWithRequest).toBe(mockCheckPrivilegesWithRequest); + expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith( + authz.actions, + mockClusterClient, + authz.getApplicationName + ); + + expect(authz.checkPrivilegesDynamicallyWithRequest).toBe( + mockCheckPrivilegesDynamicallyWithRequest + ); + expect(checkPrivilegesDynamicallyWithRequestFactory).toHaveBeenCalledWith( + mockCheckPrivilegesWithRequest, + mockGetSpacesService + ); + + expect(authz.checkSavedObjectsPrivilegesWithRequest).toBe( + mockCheckSavedObjectsPrivilegesWithRequest + ); + expect(checkSavedObjectsPrivilegesWithRequestFactory).toHaveBeenCalledWith( + mockCheckPrivilegesWithRequest, + mockGetSpacesService + ); + + expect(authz.privileges).toBe(mockPrivilegesService); + expect(privilegesFactory).toHaveBeenCalledWith(authz.actions, mockFeaturesService); + + expect(authz.mode).toBe(mockAuthorizationMode); + expect(authorizationModeFactory).toHaveBeenCalledWith(mockLicense); +}); diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts index 32c05dc8a5ebcf8..b5f9efadbd8d0e6 100644 --- a/x-pack/plugins/security/server/authorization/index.ts +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -4,13 +4,131 @@ * you may not use this file except in compliance with the Elastic License. */ +import { UICapabilities } from 'ui/capabilities'; +import { + CoreSetup, + LoggerFactory, + KibanaRequest, + IClusterClient, +} from '../../../../../src/core/server'; + +import { FeaturesService, LegacyAPI, SpacesService } from '../plugin'; +import { Actions } from './actions'; +import { CheckPrivilegesWithRequest, checkPrivilegesWithRequestFactory } from './check_privileges'; +import { + CheckPrivilegesDynamicallyWithRequest, + checkPrivilegesDynamicallyWithRequestFactory, +} from './check_privileges_dynamically'; +import { + CheckSavedObjectsPrivilegesWithRequest, + checkSavedObjectsPrivilegesWithRequestFactory, +} from './check_saved_objects_privileges'; +import { AuthorizationMode, authorizationModeFactory } from './mode'; +import { privilegesFactory, PrivilegesService } from './privileges'; +import { initAppAuthorization } from './app_authorization'; +import { initAPIAuthorization } from './api_authorization'; +import { disableUICapabilitiesFactory } from './disable_ui_capabilities'; +import { validateFeaturePrivileges } from './validate_feature_privileges'; +import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; +import { APPLICATION_PREFIX } from '../../common/constants'; +import { SecurityLicense } from '../licensing'; + export { Actions } from './actions'; -export { createAuthorizationService } from './service'; -export { disableUICapabilitesFactory } from './disable_ui_capabilities'; -export { initAPIAuthorization } from './api_authorization'; -export { initAppAuthorization } from './app_authorization'; -export { PrivilegeSerializer } from './privilege_serializer'; -// @ts-ignore -export { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; -export { ResourceSerializer } from './resource_serializer'; -export { validateFeaturePrivileges } from './validate_feature_privileges'; +export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; + +interface SetupAuthorizationParams { + packageVersion: string; + http: CoreSetup['http']; + clusterClient: IClusterClient; + license: SecurityLicense; + loggers: LoggerFactory; + featuresService: FeaturesService; + getLegacyAPI(): Pick; + getSpacesService(): SpacesService | undefined; +} + +export interface Authorization { + actions: Actions; + checkPrivilegesWithRequest: CheckPrivilegesWithRequest; + checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest; + checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest; + getApplicationName: () => string; + mode: AuthorizationMode; + privileges: PrivilegesService; + disableUnauthorizedCapabilities: ( + request: KibanaRequest, + capabilities: UICapabilities + ) => Promise; + registerPrivilegesWithCluster: () => Promise; +} + +export function setupAuthorization({ + http, + packageVersion, + clusterClient, + license, + loggers, + featuresService, + getLegacyAPI, + getSpacesService, +}: SetupAuthorizationParams): Authorization { + const actions = new Actions(packageVersion); + const mode = authorizationModeFactory(license); + const getApplicationName = () => `${APPLICATION_PREFIX}${getLegacyAPI().kibanaIndexName}`; + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( + actions, + clusterClient, + getApplicationName + ); + const privileges = privilegesFactory(actions, featuresService); + const logger = loggers.get('authorization'); + + const authz = { + actions, + getApplicationName, + checkPrivilegesWithRequest, + checkPrivilegesDynamicallyWithRequest: checkPrivilegesDynamicallyWithRequestFactory( + checkPrivilegesWithRequest, + getSpacesService + ), + checkSavedObjectsPrivilegesWithRequest: checkSavedObjectsPrivilegesWithRequestFactory( + checkPrivilegesWithRequest, + getSpacesService + ), + mode, + privileges, + + async disableUnauthorizedCapabilities(request: KibanaRequest, capabilities: UICapabilities) { + // If we have a license which doesn't enable security, or we're a legacy user we shouldn't + // disable any ui capabilities + if (!mode.useRbacForRequest(request)) { + return capabilities; + } + + const disableUICapabilities = disableUICapabilitiesFactory( + request, + featuresService.getFeatures(), + logger, + authz + ); + + // if we're an anonymous route, we disable all ui capabilities + if (request.route.options.authRequired === false) { + return disableUICapabilities.all(capabilities); + } + + return await disableUICapabilities.usingPrivileges(capabilities); + }, + + registerPrivilegesWithCluster: async () => { + validateFeaturePrivileges(actions, featuresService.getFeatures()); + + await registerPrivilegesWithCluster(logger, privileges, getApplicationName(), clusterClient); + }, + }; + + initAPIAuthorization(http, authz, loggers.get('api-authorization')); + initAppAuthorization(http, authz, loggers.get('app-authorization'), featuresService); + + return authz; +} diff --git a/x-pack/plugins/security/server/authorization/mode.test.ts b/x-pack/plugins/security/server/authorization/mode.test.ts index 26a10295cc12739..3f6aa1f68ff0dcc 100644 --- a/x-pack/plugins/security/server/authorization/mode.test.ts +++ b/x-pack/plugins/security/server/authorization/mode.test.ts @@ -4,71 +4,68 @@ * you may not use this file except in compliance with the Elastic License. */ -import { requestFixture } from '../__tests__/__fixtures__/request'; import { authorizationModeFactory } from './mode'; -class MockXPackInfoFeature { - public getLicenseCheckResults = jest.fn(); - - constructor(allowRbac: boolean) { - this.getLicenseCheckResults.mockReturnValue({ allowRbac }); - } -} +import { httpServerMock } from '../../../../../src/core/server/mocks'; +import { licenseMock } from '../licensing/index.mock'; +import { SecurityLicenseFeatures } from '../licensing/license_features'; +import { SecurityLicense } from '../licensing'; describe(`#useRbacForRequest`, () => { - test(`throws an Error if request isn't specified`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(false); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); + let mockLicense: jest.Mocked; + beforeEach(() => { + mockLicense = licenseMock.create(); + mockLicense.getFeatures.mockReturnValue({ allowRbac: false } as SecurityLicenseFeatures); + }); + test(`throws an Error if request isn't specified`, async () => { + const mode = authorizationModeFactory(mockLicense); expect(() => mode.useRbacForRequest(undefined as any)).toThrowErrorMatchingInlineSnapshot( `"Invalid value used as weak map key"` ); }); test(`throws an Error if request is "null"`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(false); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); + const mode = authorizationModeFactory(mockLicense); expect(() => mode.useRbacForRequest(null as any)).toThrowErrorMatchingInlineSnapshot( `"Invalid value used as weak map key"` ); }); - test(`returns false if xpackInfoFeature.getLicenseCheckResults().allowRbac is false`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(false); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - const request = requestFixture(); + test(`returns false if "allowRbac" is false`, async () => { + const mode = authorizationModeFactory(mockLicense); - const result = mode.useRbacForRequest(request); + const result = mode.useRbacForRequest(httpServerMock.createKibanaRequest()); expect(result).toBe(false); }); - test(`returns false if xpackInfoFeature.getLicenseCheckResults().allowRbac is initially false, and changes to true`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(false); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - const request = requestFixture(); + test(`returns false if "allowRbac" is initially false, and changes to true`, async () => { + const mode = authorizationModeFactory(mockLicense); + const request = httpServerMock.createKibanaRequest(); expect(mode.useRbacForRequest(request)).toBe(false); - mockXpackInfoFeature.getLicenseCheckResults.mockReturnValue({ allowRbac: true }); + + mockLicense.getFeatures.mockReturnValue({ allowRbac: true } as SecurityLicenseFeatures); expect(mode.useRbacForRequest(request)).toBe(false); }); - test(`returns true if xpackInfoFeature.getLicenseCheckResults().allowRbac is true`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(true); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - const request = requestFixture(); + test(`returns true if "allowRbac" is true`, async () => { + mockLicense.getFeatures.mockReturnValue({ allowRbac: true } as SecurityLicenseFeatures); + const mode = authorizationModeFactory(mockLicense); - const result = mode.useRbacForRequest(request); + const result = mode.useRbacForRequest(httpServerMock.createKibanaRequest()); expect(result).toBe(true); }); - test(`returns true if xpackInfoFeature.getLicenseCheckResults().allowRbac is initially true, and changes to false`, async () => { - const mockXpackInfoFeature = new MockXPackInfoFeature(true); - const mode = authorizationModeFactory(mockXpackInfoFeature as any); - const request = requestFixture(); + test(`returns true if "allowRbac" is initially true, and changes to false`, async () => { + mockLicense.getFeatures.mockReturnValue({ allowRbac: true } as SecurityLicenseFeatures); + const mode = authorizationModeFactory(mockLicense); + const request = httpServerMock.createKibanaRequest(); expect(mode.useRbacForRequest(request)).toBe(true); - mockXpackInfoFeature.getLicenseCheckResults.mockReturnValue({ allowRbac: false }); + + mockLicense.getFeatures.mockReturnValue({ allowRbac: false } as SecurityLicenseFeatures); expect(mode.useRbacForRequest(request)).toBe(true); }); }); diff --git a/x-pack/plugins/security/server/authorization/mode.ts b/x-pack/plugins/security/server/authorization/mode.ts index ea4d8114171302f..948cb802e8966aa 100644 --- a/x-pack/plugins/security/server/authorization/mode.ts +++ b/x-pack/plugins/security/server/authorization/mode.ts @@ -3,32 +3,24 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Request } from 'hapi'; -import { XPackFeature } from '../../../../xpack_main/xpack_main'; + +import { KibanaRequest } from '../../../../../src/core/server'; +import { SecurityLicense } from '../licensing'; export interface AuthorizationMode { - useRbacForRequest(request: Request): boolean; + // We have to support Fake request for the BWC reasons. + useRbacForRequest(request: KibanaRequest): boolean; } -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export function authorizationModeFactory(securityXPackFeature: XPackFeature) { - const useRbacForRequestCache = new WeakMap(); - +export function authorizationModeFactory(license: SecurityLicense) { + const useRbacForRequestCache = new WeakMap(); return { - useRbacForRequest(request: Request) { + useRbacForRequest(request: KibanaRequest) { if (!useRbacForRequestCache.has(request)) { - useRbacForRequestCache.set( - request, - Boolean(securityXPackFeature.getLicenseCheckResults().allowRbac) - ); + useRbacForRequestCache.set(request, license.getFeatures().allowRbac); } - return useRbacForRequestCache.get(request); + return useRbacForRequestCache.get(request)!; }, }; } diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts index 901c002bfde06d3..b13132f6efbe5f6 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/api.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeApiBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts index 4362c79dc550e9d..c874886d908eb29 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/app.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeAppBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts index 5ed649b2726c2fb..3dbe71db93f4a3e 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/catalogue.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeCatalogueBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts index 48078a26839bb7f..172ab24eb7e514e 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/feature_privilege_builder.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { Actions } from '../../actions'; export interface FeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts index 78e1db7a980f3c1..c293319070419fb 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts @@ -5,7 +5,7 @@ */ import { flatten } from 'lodash'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { Actions } from '../../actions'; import { FeaturePrivilegeApiBuilder } from './api'; import { FeaturePrivilegeAppBuilder } from './app'; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts index 4a008fdb096195f..99a4d11fb13b776 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/management.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeManagementBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts index 3cd75233beffb1a..dd076477a9c1148 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/navlink.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeNavlinkBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts index 9bc67594b357a8e..9baa8dadc29237a 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts @@ -5,7 +5,7 @@ */ import { flatten, uniq } from 'lodash'; -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; const readOperations: string[] = ['bulk_get', 'get', 'find']; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts index fd770b4c6263b95..28a22285c2b8f0a 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/ui.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature, FeatureKibanaPrivileges } from '../../../../../../../../plugins/features/server'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; export class FeaturePrivilegeUIBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 3d673cef4053486..38d4d413c591e5d 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../../../../plugins/features/server'; +import { Feature } from '../../../../features/server'; import { Actions } from '../actions'; import { privilegesFactory } from './privileges'; @@ -42,11 +42,8 @@ describe('features', () => { }, ]; - const mockXPackMainPlugin = { - getFeatures: jest.fn().mockReturnValue(features), - }; - - const privileges = privilegesFactory(actions, mockXPackMainPlugin as any); + const mockFeaturesService = { getFeatures: jest.fn().mockReturnValue(features) }; + const privileges = privilegesFactory(actions, mockFeaturesService); const actual = privileges.get(); expect(actual).toHaveProperty('features.foo-feature', { diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index aad48584a9fca5e..c73c4be8f36ac88 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -5,22 +5,22 @@ */ import { flatten, mapValues, uniq } from 'lodash'; -import { Feature } from '../../../../../../../plugins/features/server'; -import { XPackMainPlugin } from '../../../../../xpack_main/xpack_main'; -import { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from '../../../../common/model'; +import { Feature } from '../../../../features/server'; +import { RawKibanaFeaturePrivileges, RawKibanaPrivileges } from '../../../common/model'; import { Actions } from '../actions'; import { featurePrivilegeBuilderFactory } from './feature_privilege_builder'; +import { FeaturesService } from '../../plugin'; export interface PrivilegesService { get(): RawKibanaPrivileges; } -export function privilegesFactory(actions: Actions, xpackMainPlugin: XPackMainPlugin) { +export function privilegesFactory(actions: Actions, featuresService: FeaturesService) { const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); return { get() { - const features = xpackMainPlugin.getFeatures(); + const features = featuresService.getFeatures(); const basePrivilegeFeatures = features.filter(feature => !feature.excludeFromBasePrivileges); const allActions = uniq( diff --git a/x-pack/plugins/security/server/authorization/privileges_serializer.ts b/x-pack/plugins/security/server/authorization/privileges_serializer.ts index ade90b5c52f909a..67081d4c22664f3 100644 --- a/x-pack/plugins/security/server/authorization/privileges_serializer.ts +++ b/x-pack/plugins/security/server/authorization/privileges_serializer.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RawKibanaPrivileges } from '../../../common/model'; import { PrivilegeSerializer } from './privilege_serializer'; +import { RawKibanaPrivileges } from '../../common/model'; interface SerializedPrivilege { application: string; diff --git a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts index 23a7a0f0d01ab6c..888565cd7e0ff95 100644 --- a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts +++ b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.test.ts @@ -4,215 +4,165 @@ * you may not use this file except in compliance with the Elastic License. */ +import { IClusterClient, Logger } from '../../../../../target/types/core/server'; +import { RawKibanaPrivileges } from '../../common/model'; import { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { buildRawKibanaPrivileges } from './privileges'; -jest.mock('../../../../../server/lib/get_client_shield', () => ({ - getClient: jest.fn(), -})); -jest.mock('./privileges', () => ({ - buildRawKibanaPrivileges: jest.fn(), -})); -const application = 'default-application'; - -const registerPrivilegesWithClusterTest = (description, { - settings = {}, - savedObjectTypes, - privilegeMap, - existingPrivileges, - throwErrorWhenDeletingPrivileges, - errorDeletingPrivilegeName, - throwErrorWhenGettingPrivileges, - throwErrorWhenPuttingPrivileges, - assert -}) => { - const registerMockCallWithInternalUser = () => { - const callWithInternalUser = jest.fn(); - getClient.mockReturnValue({ - callWithInternalUser, - }); - return callWithInternalUser; - }; - - const defaultVersion = 'default-version'; - - const createMockServer = ({ privilegeMap }) => { - const mockServer = { - config: jest.fn().mockReturnValue({ - get: jest.fn(), - }), - log: jest.fn(), - plugins: { - security: { - authorization: { - actions: Symbol(), - application, - privileges: { - get: () => privilegeMap - } - } - } - } - }; - - const defaultSettings = { - 'pkg.version': defaultVersion, - }; - - mockServer.config().get.mockImplementation(key => { - return key in settings ? settings[key] : defaultSettings[key]; - }); - - mockServer.savedObjects = { - types: savedObjectTypes - }; +import { elasticsearchServiceMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; - return mockServer; - }; - - const createExpectUpdatedPrivileges = (mockServer, mockCallWithInternalUser, error) => { - return (postPrivilegesBody, deletedPrivileges = []) => { +const application = 'default-application'; +const registerPrivilegesWithClusterTest = ( + description: string, + { + privilegeMap, + existingPrivileges, + throwErrorWhenGettingPrivileges, + throwErrorWhenPuttingPrivileges, + assert, + }: { + privilegeMap: RawKibanaPrivileges; + existingPrivileges?: Record> | null; + throwErrorWhenGettingPrivileges?: Error; + throwErrorWhenPuttingPrivileges?: Error; + assert: (arg: { + expectUpdatedPrivileges: (postPrivilegesBody: any, deletedPrivileges?: string[]) => void; + expectDidntUpdatePrivileges: () => void; + expectErrorThrown: (expectedErrorMessage: string) => void; + }) => void; + } +) => { + const createExpectUpdatedPrivileges = ( + mockClusterClient: jest.Mocked, + mockLogger: jest.Mocked, + error: Error + ) => { + return (postPrivilegesBody: any, deletedPrivileges: string[] = []) => { expect(error).toBeUndefined(); - expect(mockCallWithInternalUser).toHaveBeenCalledTimes(2 + deletedPrivileges.length); - expect(mockCallWithInternalUser).toHaveBeenCalledWith('shield.getPrivilege', { + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes( + 2 + deletedPrivileges.length + ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.getPrivilege', { privilege: application, }); - expect(mockCallWithInternalUser).toHaveBeenCalledWith( - 'shield.postPrivileges', - { - body: postPrivilegesBody, - } - ); + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith('shield.postPrivileges', { + body: postPrivilegesBody, + }); for (const deletedPrivilege of deletedPrivileges) { - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], + expect(mockLogger.debug).toHaveBeenCalledWith( `Deleting Kibana Privilege ${deletedPrivilege} from Elasticearch for ${application}` ); - expect(mockCallWithInternalUser).toHaveBeenCalledWith( + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledWith( 'shield.deletePrivilege', - { - application, - privilege: deletedPrivilege - } + { application, privilege: deletedPrivilege } ); } - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], + + expect(mockLogger.debug).toHaveBeenCalledWith( `Registering Kibana Privileges with Elasticsearch for ${application}` ); - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], - `Updated Kibana Privileges with Elasticearch for ${application}` + expect(mockLogger.debug).toHaveBeenCalledWith( + `Updated Kibana Privileges with Elasticsearch for ${application}` ); }; }; - const createExpectDidntUpdatePrivileges = (mockServer, mockCallWithInternalUser, error) => { + const createExpectDidntUpdatePrivileges = ( + mockClusterClient: jest.Mocked, + mockLogger: Logger, + error: Error + ) => { return () => { expect(error).toBeUndefined(); - expect(mockCallWithInternalUser).toHaveBeenCalledTimes(1); - expect(mockCallWithInternalUser).toHaveBeenLastCalledWith('shield.getPrivilege', { - privilege: application + expect(mockClusterClient.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockClusterClient.callAsInternalUser).toHaveBeenLastCalledWith('shield.getPrivilege', { + privilege: application, }); - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], + expect(mockLogger.debug).toHaveBeenCalledWith( `Registering Kibana Privileges with Elasticsearch for ${application}` ); - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'debug'], + expect(mockLogger.debug).toHaveBeenCalledWith( `Kibana Privileges already registered with Elasticearch for ${application}` ); }; }; - const createExpectErrorThrown = (mockServer, actualError) => { - return (expectedErrorMessage) => { + const createExpectErrorThrown = (mockLogger: Logger, actualError: Error) => { + return (expectedErrorMessage: string) => { expect(actualError).toBeDefined(); expect(actualError).toBeInstanceOf(Error); expect(actualError.message).toEqual(expectedErrorMessage); - if (throwErrorWhenDeletingPrivileges) { - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'error'], - `Error deleting Kibana Privilege ${errorDeletingPrivilegeName}` - ); - } - - expect(mockServer.log).toHaveBeenCalledWith( - ['security', 'error'], + expect(mockLogger.error).toHaveBeenCalledWith( `Error registering Kibana Privileges with Elasticsearch for ${application}: ${expectedErrorMessage}` ); }; }; test(description, async () => { - const mockServer = createMockServer({ - privilegeMap - }); - const mockCallWithInternalUser = registerMockCallWithInternalUser() - .mockImplementation((api) => { - switch(api) { - case 'shield.getPrivilege': { - if (throwErrorWhenGettingPrivileges) { - throw throwErrorWhenGettingPrivileges; - } - - // ES returns an empty object if we don't have any privileges - if (!existingPrivileges) { - return {}; - } - - return existingPrivileges; + const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.callAsInternalUser.mockImplementation(async api => { + switch (api) { + case 'shield.getPrivilege': { + if (throwErrorWhenGettingPrivileges) { + throw throwErrorWhenGettingPrivileges; } - case 'shield.deletePrivilege': { - if (throwErrorWhenDeletingPrivileges) { - throw throwErrorWhenDeletingPrivileges; - } - break; + // ES returns an empty object if we don't have any privileges + if (!existingPrivileges) { + return {}; } - case 'shield.postPrivileges': { - if (throwErrorWhenPuttingPrivileges) { - throw throwErrorWhenPuttingPrivileges; - } - return; - } - default: { - expect(true).toBe(false); + return existingPrivileges; + } + case 'shield.deletePrivilege': { + break; + } + case 'shield.postPrivileges': { + if (throwErrorWhenPuttingPrivileges) { + throw throwErrorWhenPuttingPrivileges; } + + return; } - }); + default: { + expect(true).toBe(false); + } + } + }); + const mockLogger = loggingServiceMock.create().get() as jest.Mocked; let error; try { - await registerPrivilegesWithCluster(mockServer); + await registerPrivilegesWithCluster( + mockLogger, + { get: jest.fn().mockReturnValue(privilegeMap) }, + application, + mockClusterClient + ); } catch (err) { error = err; } assert({ - expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockServer, mockCallWithInternalUser, error), - expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges(mockServer, mockCallWithInternalUser, error), - expectErrorThrown: createExpectErrorThrown(mockServer, error), - mocks: { - buildRawKibanaPrivileges, - server: mockServer, - } + expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockClusterClient, mockLogger, error), + expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges( + mockClusterClient, + mockLogger, + error + ), + expectErrorThrown: createExpectErrorThrown(mockLogger, error), }); }); }; registerPrivilegesWithClusterTest(`inserts privileges when we don't have any existing privileges`, { privilegeMap: { - features: {}, global: { - all: ['action:all'] + all: ['action:all'], }, space: { - read: ['action:read'] + read: ['action:read'], }, features: { foo: { @@ -220,11 +170,11 @@ registerPrivilegesWithClusterTest(`inserts privileges when we don't have any exi }, bar: { read: ['action:bar_read'], - } + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: null, assert: ({ expectUpdatedPrivileges }) => { @@ -259,10 +209,10 @@ registerPrivilegesWithClusterTest(`inserts privileges when we don't have any exi name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`deletes no-longer specified privileges`, { @@ -272,7 +222,7 @@ registerPrivilegesWithClusterTest(`deletes no-longer specified privileges`, { all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, reserved: {}, }, @@ -307,49 +257,51 @@ registerPrivilegesWithClusterTest(`deletes no-longer specified privileges`, { name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges({ - [application]: { - all: { - application, - name: 'all', - actions: ['action:foo'], - metadata: {}, + expectUpdatedPrivileges( + { + [application]: { + all: { + application, + name: 'all', + actions: ['action:foo'], + metadata: {}, + }, + space_read: { + application, + name: 'space_read', + actions: ['action:bar'], + metadata: {}, + }, }, - space_read: { - application, - name: 'space_read', - actions: ['action:bar'], - metadata: {}, - } - } - }, ['read', 'space_baz', 'reserved_customApplication']); - } + }, + ['read', 'space_baz', 'reserved_customApplication'] + ); + }, }); registerPrivilegesWithClusterTest(`updates privileges when global actions don't match`, { privilegeMap: { - features: {}, global: { - all: ['action:foo'] + all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:baz'] + all: ['action:baz'], }, bar: { - read: ['action:quz'] - } + read: ['action:quz'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -380,8 +332,8 @@ registerPrivilegesWithClusterTest(`updates privileges when global actions don't name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -415,32 +367,31 @@ registerPrivilegesWithClusterTest(`updates privileges when global actions don't name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when space actions don't match`, { privilegeMap: { - features: {}, global: { - all: ['action:foo'] + all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:baz'] + all: ['action:baz'], }, bar: { - read: ['action:quz'] - } + read: ['action:quz'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -471,8 +422,8 @@ registerPrivilegesWithClusterTest(`updates privileges when space actions don't m name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -506,32 +457,31 @@ registerPrivilegesWithClusterTest(`updates privileges when space actions don't m name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when feature actions don't match`, { privilegeMap: { - features: {}, global: { - all: ['action:foo'] + all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:baz'] + all: ['action:baz'], }, bar: { - read: ['action:quz'] - } + read: ['action:quz'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -562,8 +512,8 @@ registerPrivilegesWithClusterTest(`updates privileges when feature actions don't name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -597,29 +547,28 @@ registerPrivilegesWithClusterTest(`updates privileges when feature actions don't name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when reserved actions don't match`, { privilegeMap: { - features: {}, global: { - all: ['action:foo'] + all: ['action:foo'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:baz'] - } + all: ['action:baz'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -645,8 +594,8 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved actions don' name: 'reserved_customApplication', actions: ['action:not-customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -674,29 +623,29 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved actions don' name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when global privilege added`, { privilegeMap: { global: { all: ['action:foo'], - read: ['action:quz'] + read: ['action:quz'], }, space: { - read: ['action:bar'] + read: ['action:bar'], }, features: { foo: { - all: ['action:foo-all'] - } + all: ['action:foo-all'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -723,8 +672,8 @@ registerPrivilegesWithClusterTest(`updates privileges when global privilege adde name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -758,10 +707,10 @@ registerPrivilegesWithClusterTest(`updates privileges when global privilege adde name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when space privilege added`, { @@ -771,16 +720,16 @@ registerPrivilegesWithClusterTest(`updates privileges when space privilege added }, space: { all: ['action:bar'], - read: ['action:quz'] + read: ['action:quz'], }, features: { foo: { - all: ['action:foo-all'] - } + all: ['action:foo-all'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -807,8 +756,8 @@ registerPrivilegesWithClusterTest(`updates privileges when space privilege added name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -842,15 +791,14 @@ registerPrivilegesWithClusterTest(`updates privileges when space privilege added name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when feature privilege added`, { privilegeMap: { - features: {}, global: { all: ['action:foo'], }, @@ -860,12 +808,12 @@ registerPrivilegesWithClusterTest(`updates privileges when feature privilege add features: { foo: { all: ['action:foo-all'], - read: ['action:foo-read'] - } + read: ['action:foo-read'], + }, }, reserved: { - customApplication: ['action:customApplication'] - } + customApplication: ['action:customApplication'], + }, }, existingPrivileges: { [application]: { @@ -892,8 +840,8 @@ registerPrivilegesWithClusterTest(`updates privileges when feature privilege add name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -927,15 +875,14 @@ registerPrivilegesWithClusterTest(`updates privileges when feature privilege add name: 'reserved_customApplication', actions: ['action:customApplication'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`updates privileges when reserved privilege added`, { privilegeMap: { - features: {}, global: { all: ['action:foo'], }, @@ -945,12 +892,12 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved privilege ad features: { foo: { all: ['action:foo-all'], - } + }, }, reserved: { customApplication1: ['action:customApplication1'], - customApplication2: ['action:customApplication2'] - } + customApplication2: ['action:customApplication2'], + }, }, existingPrivileges: { [application]: { @@ -977,8 +924,8 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved privilege ad name: 'reserved_customApplication1', actions: ['action:customApplication1'], metadata: {}, - } - } + }, + }, }, assert: ({ expectUpdatedPrivileges }) => { expectUpdatedPrivileges({ @@ -1012,28 +959,28 @@ registerPrivilegesWithClusterTest(`updates privileges when reserved privilege ad name: 'reserved_customApplication2', actions: ['action:customApplication2'], metadata: {}, - } - } + }, + }, }); - } + }, }); registerPrivilegesWithClusterTest(`doesn't update privileges when order of actions differ`, { privilegeMap: { global: { - all: ['action:foo', 'action:quz'] + all: ['action:foo', 'action:quz'], }, space: { - read: ['action:bar', 'action:quz'] + read: ['action:bar', 'action:quz'], }, features: { foo: { - all: ['action:foo-all', 'action:bar-all'] - } + all: ['action:foo-all', 'action:bar-all'], + }, }, reserved: { - customApplication: ['action:customApplication1', 'action:customApplication2'] - } + customApplication: ['action:customApplication1', 'action:customApplication2'], + }, }, existingPrivileges: { [application]: { @@ -1060,12 +1007,12 @@ registerPrivilegesWithClusterTest(`doesn't update privileges when order of actio name: 'reserved_customApplication', actions: ['action:customApplication2', 'action:customApplication1'], metadata: {}, - } - } + }, + }, }, assert: ({ expectDidntUpdatePrivileges }) => { expectDidntUpdatePrivileges(); - } + }, }); registerPrivilegesWithClusterTest(`throws and logs error when errors getting privileges`, { @@ -1078,17 +1025,17 @@ registerPrivilegesWithClusterTest(`throws and logs error when errors getting pri throwErrorWhenGettingPrivileges: new Error('Error getting privileges'), assert: ({ expectErrorThrown }) => { expectErrorThrown('Error getting privileges'); - } + }, }); registerPrivilegesWithClusterTest(`throws and logs error when errors putting privileges`, { privilegeMap: { features: {}, global: { - all: [] + all: [], }, space: { - read: [] + read: [], }, reserved: {}, }, @@ -1096,5 +1043,5 @@ registerPrivilegesWithClusterTest(`throws and logs error when errors putting pri throwErrorWhenPuttingPrivileges: new Error('Error putting privileges'), assert: ({ expectErrorThrown }) => { expectErrorThrown('Error putting privileges'); - } + }, }); diff --git a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts index 0150913d1b62b47..22e7830d20e28ee 100644 --- a/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts +++ b/x-pack/plugins/security/server/authorization/register_privileges_with_cluster.ts @@ -4,15 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { difference, isEmpty, isEqual } from 'lodash'; -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { serializePrivileges } from './privileges_serializer'; - -export async function registerPrivilegesWithCluster(server) { +import { isEqual, difference } from 'lodash'; +import { IClusterClient, Logger } from '../../../../../src/core/server'; - const { application, privileges } = server.plugins.security.authorization; +import { serializePrivileges } from './privileges_serializer'; +import { PrivilegesService } from './privileges'; - const arePrivilegesEqual = (existingPrivileges, expectedPrivileges) => { +export async function registerPrivilegesWithCluster( + logger: Logger, + privileges: PrivilegesService, + application: string, + clusterClient: IClusterClient +) { + const arePrivilegesEqual = ( + existingPrivileges: Record, + expectedPrivileges: Record + ) => { // when comparing privileges, the order of the actions doesn't matter, lodash's isEqual // doesn't know how to compare Sets return isEqual(existingPrivileges, expectedPrivileges, (value, other, key) => { @@ -22,52 +29,64 @@ export async function registerPrivilegesWithCluster(server) { // before comparing. return isEqual([...value].sort(), [...other].sort()); } + + // Lodash types aren't correct, `undefined` should be supported as a return value here and it + // has special meaning. + return undefined as any; }); }; - const getPrivilegesToDelete = (existingPrivileges, expectedPrivileges) => { - if (isEmpty(existingPrivileges)) { + const getPrivilegesToDelete = ( + existingPrivileges: Record, + expectedPrivileges: Record + ) => { + if (Object.keys(existingPrivileges).length === 0) { return []; } - return difference(Object.keys(existingPrivileges[application]), Object.keys(expectedPrivileges[application])); + return difference( + Object.keys(existingPrivileges[application]), + Object.keys(expectedPrivileges[application]) + ); }; const expectedPrivileges = serializePrivileges(application, privileges.get()); - server.log(['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}`); - - const callCluster = getClient(server).callWithInternalUser; + logger.debug(`Registering Kibana Privileges with Elasticsearch for ${application}`); try { // we only want to post the privileges when they're going to change as Elasticsearch has // to clear the role cache to get these changes reflected in the _has_privileges API - const existingPrivileges = await callCluster(`shield.getPrivilege`, { privilege: application }); + const existingPrivileges = await clusterClient.callAsInternalUser('shield.getPrivilege', { + privilege: application, + }); if (arePrivilegesEqual(existingPrivileges, expectedPrivileges)) { - server.log(['security', 'debug'], `Kibana Privileges already registered with Elasticearch for ${application}`); + logger.debug(`Kibana Privileges already registered with Elasticearch for ${application}`); return; } const privilegesToDelete = getPrivilegesToDelete(existingPrivileges, expectedPrivileges); for (const privilegeToDelete of privilegesToDelete) { - server.log(['security', 'debug'], `Deleting Kibana Privilege ${privilegeToDelete} from Elasticearch for ${application}`); + logger.debug( + `Deleting Kibana Privilege ${privilegeToDelete} from Elasticearch for ${application}` + ); try { - await callCluster('shield.deletePrivilege', { + await clusterClient.callAsInternalUser('shield.deletePrivilege', { application, - privilege: privilegeToDelete + privilege: privilegeToDelete, }); } catch (err) { - server.log(['security', 'error'], `Error deleting Kibana Privilege ${privilegeToDelete}`); + logger.error(`Error deleting Kibana Privilege ${privilegeToDelete}`); throw err; } } - await callCluster('shield.postPrivileges', { - body: expectedPrivileges - }); - server.log(['security', 'debug'], `Updated Kibana Privileges with Elasticearch for ${application}`); + await clusterClient.callAsInternalUser('shield.postPrivileges', { body: expectedPrivileges }); + logger.debug(`Updated Kibana Privileges with Elasticsearch for ${application}`); } catch (err) { - server.log(['security', 'error'], `Error registering Kibana Privileges with Elasticsearch for ${application}: ${err.message}`); + logger.error( + `Error registering Kibana Privileges with Elasticsearch for ${application}: ${err.message}` + ); throw err; } } diff --git a/x-pack/plugins/security/server/authorization/service.test.mocks.ts b/x-pack/plugins/security/server/authorization/service.test.mocks.ts index a766b60894d9962..5cd2eac20094dde 100644 --- a/x-pack/plugins/security/server/authorization/service.test.mocks.ts +++ b/x-pack/plugins/security/server/authorization/service.test.mocks.ts @@ -19,16 +19,6 @@ jest.mock('./check_saved_objects_privileges', () => ({ checkSavedObjectsPrivilegesWithRequestFactory: mockCheckSavedObjectsPrivilegesWithRequestFactory, })); -export const mockGetClient = jest.fn(); -jest.mock('../../../../../server/lib/get_client_shield', () => ({ - getClient: mockGetClient, -})); - -export const mockActionsFactory = jest.fn(); -jest.mock('./actions', () => ({ - actionsFactory: mockActionsFactory, -})); - export const mockPrivilegesFactory = jest.fn(); jest.mock('./privileges', () => ({ privilegesFactory: mockPrivilegesFactory, diff --git a/x-pack/plugins/security/server/authorization/service.test.ts b/x-pack/plugins/security/server/authorization/service.test.ts deleted file mode 100644 index a4c733a7e97170c..000000000000000 --- a/x-pack/plugins/security/server/authorization/service.test.ts +++ /dev/null @@ -1,112 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - mockActionsFactory, - mockAuthorizationModeFactory, - mockCheckPrivilegesDynamicallyWithRequestFactory, - mockCheckPrivilegesWithRequestFactory, - mockCheckSavedObjectsPrivilegesWithRequestFactory, - mockGetClient, - mockPrivilegesFactory, -} from './service.test.mocks'; - -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { actionsFactory } from './actions'; -import { checkPrivilegesWithRequestFactory } from './check_privileges'; -import { checkPrivilegesDynamicallyWithRequestFactory } from './check_privileges_dynamically'; -import { checkSavedObjectsPrivilegesWithRequestFactory } from './check_saved_objects_privileges'; -import { authorizationModeFactory } from './mode'; -import { privilegesFactory } from './privileges'; -import { createAuthorizationService } from './service'; - -const createMockConfig = (settings: Record = {}) => { - const mockConfig = { - get: jest.fn(), - }; - - mockConfig.get.mockImplementation((key: string) => settings[key]); - - return mockConfig; -}; - -test(`returns exposed services`, () => { - const kibanaIndex = '.a-kibana-index'; - const mockConfig = createMockConfig({ - 'kibana.index': kibanaIndex, - }); - const mockServer = { - expose: jest.fn(), - config: jest.fn().mockReturnValue(mockConfig), - plugins: Symbol(), - savedObjects: Symbol(), - log: Symbol(), - }; - const mockShieldClient = Symbol(); - mockGetClient.mockReturnValue(mockShieldClient); - - const mockCheckPrivilegesWithRequest = Symbol(); - mockCheckPrivilegesWithRequestFactory.mockReturnValue(mockCheckPrivilegesWithRequest); - - const mockCheckPrivilegesDynamicallyWithRequest = Symbol(); - mockCheckPrivilegesDynamicallyWithRequestFactory.mockReturnValue( - mockCheckPrivilegesDynamicallyWithRequest - ); - - const mockCheckSavedObjectsPrivilegesWithRequest = Symbol(); - mockCheckSavedObjectsPrivilegesWithRequestFactory.mockReturnValue( - mockCheckSavedObjectsPrivilegesWithRequest - ); - - const mockActions = Symbol(); - mockActionsFactory.mockReturnValue(mockActions); - const mockXpackInfoFeature = Symbol(); - const mockFeatures = Symbol(); - const mockXpackMainPlugin = { - getFeatures: () => mockFeatures, - }; - const mockPrivilegesService = Symbol(); - mockPrivilegesFactory.mockReturnValue(mockPrivilegesService); - const mockAuthorizationMode = Symbol(); - mockAuthorizationModeFactory.mockReturnValue(mockAuthorizationMode); - const mockSpaces = Symbol(); - - const authorization = createAuthorizationService( - mockServer as any, - mockXpackInfoFeature as any, - mockXpackMainPlugin as any, - mockSpaces as any - ); - - const application = `kibana-${kibanaIndex}`; - expect(getClient).toHaveBeenCalledWith(mockServer); - expect(actionsFactory).toHaveBeenCalledWith(mockConfig); - expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith( - mockActions, - application, - mockShieldClient - ); - expect(checkPrivilegesDynamicallyWithRequestFactory).toHaveBeenCalledWith( - mockCheckPrivilegesWithRequest, - mockSpaces - ); - expect(checkSavedObjectsPrivilegesWithRequestFactory).toHaveBeenCalledWith( - mockCheckPrivilegesWithRequest, - mockSpaces - ); - expect(privilegesFactory).toHaveBeenCalledWith(mockActions, mockXpackMainPlugin); - expect(authorizationModeFactory).toHaveBeenCalledWith(mockXpackInfoFeature); - - expect(authorization).toEqual({ - actions: mockActions, - application, - checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, - checkPrivilegesDynamicallyWithRequest: mockCheckPrivilegesDynamicallyWithRequest, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - mode: mockAuthorizationMode, - privileges: mockPrivilegesService, - }); -}); diff --git a/x-pack/plugins/security/server/authorization/service.ts b/x-pack/plugins/security/server/authorization/service.ts deleted file mode 100644 index 3d248adb9f8b863..000000000000000 --- a/x-pack/plugins/security/server/authorization/service.ts +++ /dev/null @@ -1,75 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Server } from 'hapi'; - -import { getClient } from '../../../../../server/lib/get_client_shield'; -import { LegacySpacesPlugin } from '../../../../spaces'; -import { XPackFeature, XPackMainPlugin } from '../../../../xpack_main/xpack_main'; -import { APPLICATION_PREFIX } from '../../../common/constants'; -import { OptionalPlugin } from '../../../../../server/lib/optional_plugin'; -import { Actions, actionsFactory } from './actions'; -import { CheckPrivilegesWithRequest, checkPrivilegesWithRequestFactory } from './check_privileges'; -import { - CheckPrivilegesDynamicallyWithRequest, - checkPrivilegesDynamicallyWithRequestFactory, -} from './check_privileges_dynamically'; -import { AuthorizationMode, authorizationModeFactory } from './mode'; -import { privilegesFactory, PrivilegesService } from './privileges'; -import { - CheckSavedObjectsPrivilegesWithRequest, - checkSavedObjectsPrivilegesWithRequestFactory, -} from './check_saved_objects_privileges'; - -export interface AuthorizationService { - actions: Actions; - application: string; - checkPrivilegesWithRequest: CheckPrivilegesWithRequest; - checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest; - checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest; - mode: AuthorizationMode; - privileges: PrivilegesService; -} - -export function createAuthorizationService( - server: Server, - securityXPackFeature: XPackFeature, - xpackMainPlugin: XPackMainPlugin, - spaces: OptionalPlugin -): AuthorizationService { - const shieldClient = getClient(server); - const config = server.config(); - - const actions = actionsFactory(config); - const application = `${APPLICATION_PREFIX}${config.get('kibana.index')}`; - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory( - actions, - application, - shieldClient - ); - const checkPrivilegesDynamicallyWithRequest = checkPrivilegesDynamicallyWithRequestFactory( - checkPrivilegesWithRequest, - spaces - ); - - const checkSavedObjectsPrivilegesWithRequest = checkSavedObjectsPrivilegesWithRequestFactory( - checkPrivilegesWithRequest, - spaces - ); - - const mode = authorizationModeFactory(securityXPackFeature); - const privileges = privilegesFactory(actions, xpackMainPlugin); - - return { - actions, - application, - checkPrivilegesWithRequest, - checkPrivilegesDynamicallyWithRequest, - checkSavedObjectsPrivilegesWithRequest, - mode, - privileges, - }; -} diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts index 6745a00091ceef9..3dc3ae03b18cbfa 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.test.ts @@ -4,20 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../../../plugins/features/server'; -import { actionsFactory } from './actions'; +import { Feature } from '../../../features/server'; +import { Actions } from './actions'; import { validateFeaturePrivileges } from './validate_feature_privileges'; -const mockConfig = { - get: (key: string) => { - if (key === 'pkg.version') { - return `1.0.0-zeta1`; - } - - throw new Error(`Mock config doesn't know about key ${key}`); - }, -}; -const actions = actionsFactory(mockConfig); +const actions = new Actions('1.0.0-zeta1'); it(`doesn't allow read to grant privileges which aren't also included in all`, () => { const feature: Feature = { diff --git a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts index 0e40ae36c4f729b..ea71df7007d3fc4 100644 --- a/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts +++ b/x-pack/plugins/security/server/authorization/validate_feature_privileges.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Feature } from '../../../../../../plugins/features/server'; -import { areActionsFullyCovered } from '../../../common/privilege_calculator_utils'; +import { Feature } from '../../../features/server'; import { Actions } from './actions'; import { featurePrivilegeBuilderFactory } from './privileges/feature_privilege_builder'; +import { areActionsFullyCovered } from '../../../../legacy/plugins/security/common/privilege_calculator_utils'; export function validateFeaturePrivileges(actions: Actions, features: Feature[]) { const featurePrivilegeBuilder = featurePrivilegeBuilderFactory(actions); diff --git a/x-pack/plugins/security/server/licensing/index.mock.ts b/x-pack/plugins/security/server/licensing/index.mock.ts new file mode 100644 index 000000000000000..b38f031c4ee7dd6 --- /dev/null +++ b/x-pack/plugins/security/server/licensing/index.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SecurityLicense } from '.'; + +export const licenseMock = { + create: (): jest.Mocked => ({ + isEnabled: jest.fn().mockReturnValue(true), + getFeatures: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/licensing/index.ts b/x-pack/plugins/security/server/licensing/index.ts new file mode 100644 index 000000000000000..9ddbe86167367f3 --- /dev/null +++ b/x-pack/plugins/security/server/licensing/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SecurityLicenseService, SecurityLicense } from './license_service'; diff --git a/x-pack/plugins/security/server/licensing/license_features.ts b/x-pack/plugins/security/server/licensing/license_features.ts new file mode 100644 index 000000000000000..6b6c86d48c21e43 --- /dev/null +++ b/x-pack/plugins/security/server/licensing/license_features.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Describes Security plugin features that depend on license. + */ +export interface SecurityLicenseFeatures { + /** + * Indicates whether we show login page or skip it. + */ + readonly showLogin: boolean; + + /** + * Indicates whether we allow login or disable it on the login page. + */ + readonly allowLogin: boolean; + + /** + * Indicates whether we show security links throughout the kibana app. + */ + readonly showLinks: boolean; + + /** + * Indicates whether we allow users to define document level security in roles. + */ + readonly allowRoleDocumentLevelSecurity: boolean; + + /** + * Indicates whether we allow users to define field level security in roles. + */ + readonly allowRoleFieldLevelSecurity: boolean; + + /** + * Indicates whether we allow Role-based access control (RBAC). + */ + readonly allowRbac: boolean; + + /** + * Describes the layout of the login form if it's displayed. + */ + readonly layout?: string; + + /** + * Message to show when security links are clicked throughout the kibana app. + */ + readonly linksMessage?: string; +} diff --git a/x-pack/plugins/security/server/licensing/license_service.test.ts b/x-pack/plugins/security/server/licensing/license_service.test.ts index ad5c59f36eb4436..16d7599ca4b1a4e 100644 --- a/x-pack/plugins/security/server/licensing/license_service.test.ts +++ b/x-pack/plugins/security/server/licensing/license_service.test.ts @@ -4,33 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import { checkLicense } from '../check_license'; - -describe('check_license', function () { - - let mockXPackInfo; - - beforeEach(function () { - mockXPackInfo = { - isAvailable: sinon.stub(), - isXpackUnavailable: sinon.stub(), - feature: sinon.stub(), - license: sinon.stub({ - isOneOf() { }, - }) - }; - - mockXPackInfo.isAvailable.returns(true); - }); - +import { ILicense } from '../../../licensing/server'; +import { SecurityLicenseService } from './license_service'; + +function getMockRawLicense({ isAvailable = false } = {}) { + return ({ + isAvailable, + isOneOf: jest.fn(), + getFeature: jest.fn(), + } as unknown) as jest.Mocked; +} + +describe('license features', function() { it('should display error when ES is unavailable', () => { - mockXPackInfo.isAvailable.returns(false); - mockXPackInfo.isXpackUnavailable.returns(false); - - const licenseCheckResults = checkLicense(mockXPackInfo); - expect(licenseCheckResults).to.be.eql({ + const serviceSetup = new SecurityLicenseService().setup(); + expect(serviceSetup.license.getFeatures()).toEqual({ showLogin: true, allowLogin: false, showLinks: false, @@ -42,11 +30,9 @@ describe('check_license', function () { }); it('should display error when X-Pack is unavailable', () => { - mockXPackInfo.isAvailable.returns(false); - mockXPackInfo.isXpackUnavailable.returns(true); - - const licenseCheckResults = checkLicense(mockXPackInfo); - expect(licenseCheckResults).to.be.eql({ + const serviceSetup = new SecurityLicenseService().setup(); + serviceSetup.update(getMockRawLicense({ isAvailable: false })); + expect(serviceSetup.license.getFeatures()).toEqual({ showLogin: true, allowLogin: false, showLinks: false, @@ -57,70 +43,56 @@ describe('check_license', function () { }); }); + it('should show login page and other security elements, allow RBAC but forbid document level security if license is not platinum or trial.', () => { + const mockRawLicense = getMockRawLicense({ isAvailable: true }); + mockRawLicense.isOneOf.mockImplementation(licenses => + Array.isArray(licenses) ? licenses.includes('basic') : licenses === 'basic' + ); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true } as any); - it('should show login page and other security elements if license is basic and security is enabled.', () => { - mockXPackInfo.license.isOneOf.withArgs(['basic']).returns(true); - mockXPackInfo.license.isOneOf.withArgs(['platinum', 'trial']).returns(false); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return true; } - }); - - const licenseCheckResults = checkLicense(mockXPackInfo); - expect(licenseCheckResults).to.be.eql({ + const serviceSetup = new SecurityLicenseService().setup(); + serviceSetup.update(mockRawLicense); + expect(serviceSetup.license.getFeatures()).toEqual({ showLogin: true, allowLogin: true, showLinks: true, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, - allowRbac: true + allowRbac: true, }); + expect(mockRawLicense.getFeature).toHaveBeenCalledTimes(1); + expect(mockRawLicense.getFeature).toHaveBeenCalledWith('security'); }); it('should not show login page or other security elements if security is disabled in Elasticsearch.', () => { - mockXPackInfo.license.isOneOf.withArgs(['basic']).returns(false); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return false; } - }); + const mockRawLicense = getMockRawLicense({ isAvailable: true }); + mockRawLicense.isOneOf.mockReturnValue(false); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: false, isAvailable: true } as any); - const licenseCheckResults = checkLicense(mockXPackInfo); - expect(licenseCheckResults).to.be.eql({ + const serviceSetup = new SecurityLicenseService().setup(); + serviceSetup.update(mockRawLicense); + expect(serviceSetup.license.getFeatures()).toEqual({ showLogin: false, allowLogin: false, showLinks: false, allowRoleDocumentLevelSecurity: false, allowRoleFieldLevelSecurity: false, allowRbac: false, - linksMessage: 'Access is denied because Security is disabled in Elasticsearch.' - }); - }); - - it('should allow to login and allow RBAC but forbid document level security if license is not platinum or trial.', () => { - mockXPackInfo.license.isOneOf - .returns(false) - .withArgs(['platinum', 'trial']).returns(false); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return true; } - }); - - expect(checkLicense(mockXPackInfo)).to.be.eql({ - showLogin: true, - allowLogin: true, - showLinks: true, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: true, + linksMessage: 'Access is denied because Security is disabled in Elasticsearch.', }); }); it('should allow to login, allow RBAC and document level security if license is platinum or trial.', () => { - mockXPackInfo.license.isOneOf - .returns(false) - .withArgs(['platinum', 'trial']).returns(true); - mockXPackInfo.feature.withArgs('security').returns({ - isEnabled: () => { return true; } + const mockRawLicense = getMockRawLicense({ isAvailable: true }); + mockRawLicense.isOneOf.mockImplementation(licenses => { + const licenseArray = [licenses].flat(); + return licenseArray.includes('trial') || licenseArray.includes('platinum'); }); + mockRawLicense.getFeature.mockReturnValue({ isEnabled: true, isAvailable: true } as any); - expect(checkLicense(mockXPackInfo)).to.be.eql({ + const serviceSetup = new SecurityLicenseService().setup(); + serviceSetup.update(mockRawLicense); + expect(serviceSetup.license.getFeatures()).toEqual({ showLogin: true, allowLogin: true, showLinks: true, @@ -129,5 +101,4 @@ describe('check_license', function () { allowRbac: true, }); }); - }); diff --git a/x-pack/plugins/security/server/licensing/license_service.ts b/x-pack/plugins/security/server/licensing/license_service.ts index 2a6650e9e2b0e76..58c445de9319de1 100644 --- a/x-pack/plugins/security/server/licensing/license_service.ts +++ b/x-pack/plugins/security/server/licensing/license_service.ts @@ -4,60 +4,83 @@ * you may not use this file except in compliance with the Elastic License. */ -/** - * @typedef {Object} LicenseCheckResult Result of the license check. - * @property {boolean} showLogin Indicates whether we show login page or skip it. - * @property {boolean} allowLogin Indicates whether we allow login or disable it on the login page. - * @property {boolean} showLinks Indicates whether we show security links throughout the kibana app. - * @property {boolean} allowRoleDocumentLevelSecurity Indicates whether we allow users to define document level - * security in roles. - * @property {boolean} allowRoleFieldLevelSecurity Indicates whether we allow users to define field level security - * in roles - * @property {string} [linksMessage] Message to show when security links are clicked throughout the kibana app. - */ +import { deepFreeze } from '../../../../../src/core/utils'; +import { ILicense } from '../../../licensing/server'; +import { SecurityLicenseFeatures } from './license_features'; -/** - * Returns object that defines behavior of the security related areas (login page, user management etc.) based - * on the license information extracted from the xPackInfo. - * @param {XPackInfo} xPackInfo XPackInfo instance to extract license information from. - * @returns {LicenseCheckResult} - */ -export function checkLicense(xPackInfo) { - // If, for some reason, we cannot get license information from Elasticsearch, - // assume worst-case and lock user at login screen. - if (!xPackInfo.isAvailable()) { - return { - showLogin: true, - allowLogin: false, - showLinks: false, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: false, - layout: xPackInfo.isXpackUnavailable() ? 'error-xpack-unavailable' : 'error-es-unavailable' - }; - } +export interface SecurityLicense { + isEnabled(): boolean; + getFeatures(): SecurityLicenseFeatures; +} + +export class SecurityLicenseService { + public setup() { + let rawLicense: Readonly | undefined; - const isEnabledInES = xPackInfo.feature('security').isEnabled(); - if (!isEnabledInES) { return { - showLogin: false, - allowLogin: false, - showLinks: false, - allowRoleDocumentLevelSecurity: false, - allowRoleFieldLevelSecurity: false, - allowRbac: false, - linksMessage: 'Access is denied because Security is disabled in Elasticsearch.' + update(newRawLicense: Readonly) { + rawLicense = newRawLicense; + }, + + license: deepFreeze({ + isEnabled() { + if (!rawLicense) { + return false; + } + + const securityFeature = rawLicense.getFeature('security'); + return ( + securityFeature !== undefined && + securityFeature.isAvailable && + securityFeature.isEnabled + ); + }, + + /** + * Returns up-do-date Security related features based on the last known license. + */ + getFeatures(): SecurityLicenseFeatures { + // If, for some reason, we cannot get license information from Elasticsearch, + // assume worst-case and lock user at login screen. + if (rawLicense === undefined || !rawLicense.isAvailable) { + return { + showLogin: true, + allowLogin: false, + showLinks: false, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: false, + layout: + rawLicense !== undefined && !rawLicense.isAvailable + ? 'error-xpack-unavailable' + : 'error-es-unavailable', + }; + } + + if (!this.isEnabled()) { + return { + showLogin: false, + allowLogin: false, + showLinks: false, + allowRoleDocumentLevelSecurity: false, + allowRoleFieldLevelSecurity: false, + allowRbac: false, + linksMessage: 'Access is denied because Security is disabled in Elasticsearch.', + }; + } + + const isLicensePlatinumOrTrial = rawLicense.isOneOf(['platinum', 'trial']); + return { + showLogin: true, + allowLogin: true, + showLinks: true, + // Only platinum and trial licenses are compliant with field- and document-level security. + allowRoleDocumentLevelSecurity: isLicensePlatinumOrTrial, + allowRoleFieldLevelSecurity: isLicensePlatinumOrTrial, + allowRbac: true, + }; + }, + }), }; } - - const isLicensePlatinumOrTrial = xPackInfo.license.isOneOf(['platinum', 'trial']); - return { - showLogin: true, - allowLogin: true, - showLinks: true, - // Only platinum and trial licenses are compliant with field- and document-level security. - allowRoleDocumentLevelSecurity: isLicensePlatinumOrTrial, - allowRoleFieldLevelSecurity: isLicensePlatinumOrTrial, - allowRbac: true, - }; } diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts new file mode 100644 index 000000000000000..d5c08d5ab1ab945 --- /dev/null +++ b/x-pack/plugins/security/server/mocks.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginSetupContract } from './plugin'; + +import { authenticationMock } from './authentication/index.mock'; +import { authorizationMock } from './authorization/index.mock'; + +function createSetupMock() { + const mockAuthz = authorizationMock.create(); + return { + authc: authenticationMock.create(), + authz: { + actions: mockAuthz.actions, + checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, + mode: mockAuthz.mode, + }, + registerSpacesService: jest.fn(), + __legacyCompat: {} as PluginSetupContract['__legacyCompat'], + }; +} + +export const securityMock = { + createSetup: createSetupMock, +}; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 7fa8f20476f90e0..b0e2ae717683482 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -4,16 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; - +import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; -import { Plugin } from './plugin'; import { IClusterClient, CoreSetup } from '../../../../src/core/server'; +import { Plugin, PluginSetupDependencies } from './plugin'; + +import { coreMock, elasticsearchServiceMock } from '../../../../src/core/server/mocks'; describe('Security Plugin', () => { let plugin: Plugin; let mockCoreSetup: MockedKeys; let mockClusterClient: jest.Mocked; + let mockDependencies: PluginSetupDependencies; beforeEach(() => { plugin = new Plugin( coreMock.createPluginInitializerContext({ @@ -33,12 +35,33 @@ describe('Security Plugin', () => { mockCoreSetup.elasticsearch.createClient.mockReturnValue( (mockClusterClient as unknown) as jest.Mocked ); + + mockDependencies = { licensing: { license$: of({}) } } as PluginSetupDependencies; }); describe('setup()', () => { it('exposes proper contract', async () => { - await expect(plugin.setup(mockCoreSetup)).resolves.toMatchInlineSnapshot(` + await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(` Object { + "__legacyCompat": Object { + "config": Object { + "authc": Object { + "providers": Array [ + "saml", + "token", + ], + }, + "cookieName": "sid", + "secureCookies": true, + "sessionTimeout": 1500, + }, + "license": Object { + "getFeatures": [Function], + "isEnabled": [Function], + }, + "registerLegacyAPI": [Function], + "registerPrivilegesWithCluster": [Function], + }, "authc": Object { "createAPIKey": [Function], "getCurrentUser": [Function], @@ -47,24 +70,40 @@ describe('Security Plugin', () => { "login": [Function], "logout": [Function], }, - "config": Object { - "authc": Object { - "providers": Array [ - "saml", - "token", - ], + "authz": Object { + "actions": Actions { + "allHack": "allHack:", + "api": ApiActions { + "prefix": "api:version:", + }, + "app": AppActions { + "prefix": "app:version:", + }, + "login": "login:", + "savedObject": SavedObjectActions { + "prefix": "saved_object:version:", + }, + "space": SpaceActions { + "prefix": "space:version:", + }, + "ui": UIActions { + "prefix": "ui:version:", + }, + "version": "version:version", + "versionNumber": "version", + }, + "checkPrivilegesWithRequest": [Function], + "mode": Object { + "useRbacForRequest": [Function], }, - "cookieName": "sid", - "secureCookies": true, - "sessionTimeout": 1500, }, - "registerLegacyAPI": [Function], + "registerSpacesService": [Function], } `); }); it('properly creates cluster client instance', async () => { - await plugin.setup(mockCoreSetup); + await plugin.setup(mockCoreSetup, mockDependencies); expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledTimes(1); expect(mockCoreSetup.elasticsearch.createClient).toHaveBeenCalledWith('security', { @@ -74,7 +113,7 @@ describe('Security Plugin', () => { }); describe('stop()', () => { - beforeEach(async () => await plugin.setup(mockCoreSetup)); + beforeEach(async () => await plugin.setup(mockCoreSetup, mockDependencies)); it('properly closes cluster client instance', async () => { expect(mockClusterClient.close).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 18717f3e132b9f5..796ef44dce81732 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Subscription } from 'rxjs'; import { first } from 'rxjs/operators'; import { IClusterClient, @@ -12,21 +13,43 @@ import { Logger, PluginInitializerContext, RecursiveReadonly, + SavedObjectsLegacyService, + LegacyRequest, } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/utils'; -import { XPackInfo } from '../../../legacy/plugins/xpack_main/server/lib/xpack_info'; -import { setupAuthentication, Authentication } from './authentication'; +import { SpacesPluginSetup } from '../../spaces/server'; +import { PluginSetupContract as FeaturesSetupContract } from '../../features/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { CapabilitiesModifier } from '../../../../src/legacy/server/capabilities'; + +import { Authentication, setupAuthentication } from './authentication'; +import { Authorization, setupAuthorization } from './authorization'; import { createConfig$ } from './config'; import { defineRoutes } from './routes'; +import { SecurityLicenseService, SecurityLicense } from './licensing'; +import { setupSavedObjects } from './saved_objects'; +import { SecurityAuditLogger } from './audit'; + +export type SpacesService = Pick< + SpacesPluginSetup['spacesService'], + 'getSpaceId' | 'namespaceToSpaceId' +>; + +export type FeaturesService = Pick; /** * Describes a set of APIs that is available in the legacy platform only and required by this plugin * to function properly. */ export interface LegacyAPI { - xpackInfo: Pick; isSystemAPIRequest: (request: KibanaRequest) => boolean; + capabilities: { registerCapabilitiesModifier: (provider: CapabilitiesModifier) => void }; + kibanaIndexName: string; cspRules: string; + savedObjects: SavedObjectsLegacyService; + auditLogger: { + log: (eventType: string, message: string, data?: Record) => void; + }; } /** @@ -34,14 +57,33 @@ export interface LegacyAPI { */ export interface PluginSetupContract { authc: Authentication; + authz: Pick; + + /** + * If Spaces plugin is available it's supposed to register its SpacesService with Security plugin + * so that Security can get space ID from the URL or namespace. We can't declare optional dependency + * to Spaces since it'd result into circular dependency between these two plugins and circular + * dependencies aren't supported by the Core. In the future we have to get rid of this implicit + * dependency. + * @param service Spaces service exposed by the Spaces plugin. + */ + registerSpacesService: (service: SpacesService) => void; + + __legacyCompat: { + registerLegacyAPI: (legacyAPI: LegacyAPI) => void; + registerPrivilegesWithCluster: () => void; + license: SecurityLicense; + config: RecursiveReadonly<{ + sessionTimeout: number | null; + secureCookies: boolean; + authc: { providers: string[] }; + }>; + }; +} - config: RecursiveReadonly<{ - sessionTimeout: number | null; - secureCookies: boolean; - authc: { providers: string[] }; - }>; - - registerLegacyAPI: (legacyAPI: LegacyAPI) => void; +export interface PluginSetupDependencies { + features: FeaturesService; + licensing: LicensingPluginSetup; } /** @@ -50,6 +92,8 @@ export interface PluginSetupContract { export class Plugin { private readonly logger: Logger; private clusterClient?: IClusterClient; + private spacesService?: SpacesService; + private licenseSubscription?: Subscription; private legacyAPI?: LegacyAPI; private readonly getLegacyAPI = () => { @@ -59,11 +103,16 @@ export class Plugin { return this.legacyAPI; }; + private readonly getSpacesService = () => this.spacesService; + constructor(private readonly initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); } - public async setup(core: CoreSetup): Promise> { + public async setup( + core: CoreSetup, + { features, licensing }: PluginSetupDependencies + ): Promise> { const config = await createConfig$(this.initializerContext, core.http.isTlsEnabled) .pipe(first()) .toPromise(); @@ -72,34 +121,82 @@ export class Plugin { plugins: [require('../../../legacy/server/lib/esjs_shield_plugin')], }); + const { license, update: updateLicense } = new SecurityLicenseService().setup(); + this.licenseSubscription = licensing.license$.subscribe(rawLicense => + updateLicense(rawLicense) + ); + const authc = await setupAuthentication({ - core, + http: core.http, + clusterClient: this.clusterClient, config, + license, + loggers: this.initializerContext.logger, + getLegacyAPI: this.getLegacyAPI, + }); + + const authz = await setupAuthorization({ + http: core.http, clusterClient: this.clusterClient, + license, loggers: this.initializerContext.logger, getLegacyAPI: this.getLegacyAPI, + packageVersion: this.initializerContext.env.packageInfo.version, + getSpacesService: this.getSpacesService, + featuresService: features, }); defineRoutes({ router: core.http.createRouter(), basePath: core.http.basePath, logger: this.initializerContext.logger.get('routes'), + clusterClient: this.clusterClient, config, authc, + authz, getLegacyAPI: this.getLegacyAPI, }); + const adminClient = await core.elasticsearch.adminClient$.pipe(first()).toPromise(); return deepFreeze({ - registerLegacyAPI: (legacyAPI: LegacyAPI) => (this.legacyAPI = legacyAPI), authc, - // We should stop exposing this config as soon as only new platform plugin consumes it. The only - // exception may be `sessionTimeout` as other parts of the app may want to know it. - config: { - sessionTimeout: config.sessionTimeout, - secureCookies: config.secureCookies, - cookieName: config.cookieName, - authc: { providers: config.authc.providers }, + authz: { + actions: authz.actions, + checkPrivilegesWithRequest: authz.checkPrivilegesWithRequest, + mode: authz.mode, + }, + + registerSpacesService: service => (this.spacesService = service), + + __legacyCompat: { + registerLegacyAPI: (legacyAPI: LegacyAPI) => { + this.legacyAPI = legacyAPI; + + setupSavedObjects({ + auditLogger: new SecurityAuditLogger(legacyAPI.auditLogger), + adminClusterClient: adminClient, + authz, + legacyAPI, + }); + + legacyAPI.capabilities.registerCapabilitiesModifier((request, capabilities) => + authz.disableUnauthorizedCapabilities(KibanaRequest.from(request), capabilities) + ); + }, + + registerPrivilegesWithCluster: async () => await authz.registerPrivilegesWithCluster(), + + license, + + // We should stop exposing this config as soon as only new platform plugin consumes it. The only + // exception may be `sessionTimeout` as other parts of the app may want to know it. + config: { + sessionTimeout: config.sessionTimeout, + secureCookies: config.secureCookies, + cookieName: config.cookieName, + authc: { providers: config.authc.providers }, + }, }, }); } @@ -115,5 +212,10 @@ export class Plugin { this.clusterClient.close(); this.clusterClient = undefined; } + + if (this.licenseSubscription) { + this.licenseSubscription.unsubscribe(); + this.licenseSubscription = undefined; + } } } diff --git a/x-pack/plugins/security/server/routes/authentication/index.test.ts b/x-pack/plugins/security/server/routes/authentication/index.test.ts new file mode 100644 index 000000000000000..cad370b7837e1e2 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/index.test.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { defineAuthenticationRoutes } from '.'; +import { ConfigType } from '../../config'; + +import { + elasticsearchServiceMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; +import { authenticationMock } from '../../authentication/index.mock'; +import { authorizationMock } from '../../authorization/index.mock'; + +describe('Authentication routes', () => { + it('does not register any SAML related routes if SAML auth provider is not enabled', () => { + const router = httpServiceMock.createRouter(); + + defineAuthenticationRoutes({ + router, + clusterClient: elasticsearchServiceMock.createClusterClient(), + basePath: httpServiceMock.createBasePath(), + logger: loggingServiceMock.create().get(), + config: { authc: { providers: ['basic'] } } as ConfigType, + authc: authenticationMock.create(), + authz: authorizationMock.create(), + getLegacyAPI: () => ({ cspRules: 'test-csp-rule' }), + }); + + const samlRoutePathPredicate = ([{ path }]: [{ path: string }, any]) => + path.startsWith('/api/security/saml/'); + expect(router.get.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.post.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.put.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.delete.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts new file mode 100644 index 000000000000000..0e3f03255dcb905 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { defineSAMLRoutes } from './saml'; +import { RouteDefinitionParams } from '..'; + +export function defineAuthenticationRoutes(params: RouteDefinitionParams) { + if (params.config.authc.providers.includes('saml')) { + defineSAMLRoutes(params); + } +} diff --git a/x-pack/plugins/security/server/routes/authentication/saml.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts index ac677519cd9375f..cdef1826ddaa889 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -5,18 +5,21 @@ */ import { Type } from '@kbn/config-schema'; -import { Authentication, AuthenticationResult, SAMLLoginStep } from '../authentication'; -import { defineAuthenticationRoutes } from './authentication'; +import { Authentication, AuthenticationResult, SAMLLoginStep } from '../../authentication'; +import { defineSAMLRoutes } from './saml'; +import { ConfigType } from '../../config'; +import { IRouter, RequestHandler, RouteConfig } from '../../../../../../src/core/server'; +import { LegacyAPI } from '../../plugin'; + import { + elasticsearchServiceMock, httpServerMock, httpServiceMock, loggingServiceMock, -} from '../../../../../src/core/server/mocks'; -import { ConfigType } from '../config'; -import { IRouter, RequestHandler, RouteConfig } from '../../../../../src/core/server'; -import { LegacyAPI } from '../plugin'; -import { authenticationMock } from '../authentication/index.mock'; -import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +} from '../../../../../../src/core/server/mocks'; +import { authenticationMock } from '../../authentication/index.mock'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; +import { authorizationMock } from '../../authorization/index.mock'; describe('SAML authentication routes', () => { let router: jest.Mocked; @@ -25,35 +28,18 @@ describe('SAML authentication routes', () => { router = httpServiceMock.createRouter(); authc = authenticationMock.create(); - defineAuthenticationRoutes({ + defineSAMLRoutes({ router, + clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createBasePath(), logger: loggingServiceMock.create().get(), config: { authc: { providers: ['saml'] } } as ConfigType, authc, + authz: authorizationMock.create(), getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI), }); }); - it('does not register any SAML related routes if SAML auth provider is not enabled', () => { - const testRouter = httpServiceMock.createRouter(); - defineAuthenticationRoutes({ - router: testRouter, - basePath: httpServiceMock.createBasePath(), - logger: loggingServiceMock.create().get(), - config: { authc: { providers: ['basic'] } } as ConfigType, - authc: authenticationMock.create(), - getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI), - }); - - const samlRoutePathPredicate = ([{ path }]: [{ path: string }, any]) => - path.startsWith('/api/security/saml/'); - expect(testRouter.get.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); - expect(testRouter.post.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); - expect(testRouter.put.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); - expect(testRouter.delete.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); - }); - describe('Assertion consumer service endpoint', () => { let routeHandler: RequestHandler; let routeConfig: RouteConfig; diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index e0c83602afffb6f..61f40e583d24ef9 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -5,19 +5,13 @@ */ import { schema } from '@kbn/config-schema'; -import { RouteDefinitionParams } from '.'; -import { SAMLLoginStep } from '../authentication'; - -export function defineAuthenticationRoutes(params: RouteDefinitionParams) { - if (params.config.authc.providers.includes('saml')) { - defineSAMLRoutes(params); - } -} +import { SAMLLoginStep } from '../../authentication'; +import { RouteDefinitionParams } from '..'; /** * Defines routes required for SAML authentication. */ -function defineSAMLRoutes({ +export function defineSAMLRoutes({ router, logger, authc, diff --git a/x-pack/plugins/security/server/routes/authorization/index.test.ts b/x-pack/plugins/security/server/routes/authorization/index.test.ts new file mode 100644 index 000000000000000..4649040dbd6f959 --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/index.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { defineAuthorizationRoutes } from '.'; +import { ConfigType } from '../../config'; +import { LegacyAPI } from '../../plugin'; + +import { + elasticsearchServiceMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../../src/core/server/mocks'; +import { authenticationMock } from '../../authentication/index.mock'; +import { authorizationMock } from '../../authorization/index.mock'; + +describe('Authentication routes', () => { + it('does not register any SAML related routes if SAML auth provider is not enabled', () => { + const router = httpServiceMock.createRouter(); + + defineAuthorizationRoutes({ + router, + clusterClient: elasticsearchServiceMock.createClusterClient(), + basePath: httpServiceMock.createBasePath(), + logger: loggingServiceMock.create().get(), + config: { authc: { providers: ['basic'] } } as ConfigType, + authc: authenticationMock.create(), + authz: authorizationMock.create(), + getLegacyAPI: () => ({ cspRules: 'test-csp-rule' } as LegacyAPI), + }); + + const samlRoutePathPredicate = ([{ path }]: [{ path: string }, any]) => + path.startsWith('/api/security/saml/'); + expect(router.get.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.post.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.put.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + expect(router.delete.mock.calls.find(samlRoutePathPredicate)).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/index.ts b/x-pack/plugins/security/server/routes/authorization/index.ts new file mode 100644 index 000000000000000..19f2bcccb04a89e --- /dev/null +++ b/x-pack/plugins/security/server/routes/authorization/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { definePrivilegesRoutes } from './privileges'; +import { defineRolesRoutes } from './roles'; +import { RouteDefinitionParams } from '..'; + +export function defineAuthorizationRoutes(params: RouteDefinitionParams) { + defineRolesRoutes(params); + definePrivilegesRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts index 16a1b0f7e35a5f9..51195734b42fbdd 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.test.ts @@ -3,11 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import Boom from 'boom'; -import { Server } from 'hapi'; -import { RawKibanaPrivileges } from '../../../../../common/model'; -import { initGetPrivilegesApi } from './get'; -import { AuthorizationService } from '../../../../lib/authorization/service'; + +import { RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +// TODO, require from licensing plugin root once https://github.com/elastic/kibana/pull/44922 is merged. +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { RawKibanaPrivileges } from '../../../../common/model'; +import { defineGetPrivilegesRoutes } from './get'; + +import { httpServerMock } from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; const createRawKibanaPrivileges: () => RawKibanaPrivileges = () => { return { @@ -34,80 +39,63 @@ const createRawKibanaPrivileges: () => RawKibanaPrivileges = () => { }; }; -const createMockServer = () => { - const mockServer = new Server({ debug: false, port: 8080 }); - - mockServer.plugins.security = { - authorization: ({ - privileges: { - get: jest.fn().mockImplementation(() => { - return createRawKibanaPrivileges(); - }), - }, - } as unknown) as AuthorizationService, - } as any; - return mockServer; -}; - interface TestOptions { - preCheckLicenseImpl?: () => void; + licenseCheckResult?: ILicenseCheck; includeActions?: boolean; - asserts: { - statusCode: number; - result: Record; - }; + asserts: { statusCode: 200 | 403; result: Record }; } describe('GET privileges', () => { const getPrivilegesTest = ( description: string, - { preCheckLicenseImpl = () => null, includeActions, asserts }: TestOptions + { licenseCheckResult = { check: LICENSE_STATUS.Valid }, includeActions, asserts }: TestOptions ) => { test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.privileges.get.mockImplementation(() => + createRawKibanaPrivileges() + ); - initGetPrivilegesApi(mockServer, pre); - const headers = { - authorization: 'foo', - }; + defineGetPrivilegesRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; - const url = `/api/security/privileges${includeActions ? '?includeActions=true' : ''}`; - - const request = { - method: 'GET', - url, + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'get', + path: `/api/security/privileges${includeActions ? '?includeActions=true' : ''}`, + query: includeActions ? { includeActions: 'true' } : undefined, headers, - }; - const { result, statusCode } = await mockServer.inject(request); + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const mockResponseResult = { status: asserts.statusCode, options: {} }; + const mockResponse = httpServerMock.createResponseFactory(); + const mockResponseFactory = + asserts.statusCode === 200 ? mockResponse.ok : mockResponse.forbidden; + mockResponseFactory.mockReturnValue(mockResponseResult); - expect(pre).toHaveBeenCalled(); - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); + const response = handler(mockContext, mockRequest, mockResponse); + + expect(response).toBe(mockResponseResult); + expect(mockResponseFactory).toHaveBeenCalledTimes(1); + expect(mockResponseFactory).toHaveBeenCalledWith(asserts.result); + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); }); }; describe('failure', () => { getPrivilegesTest(`returns result of routePreCheckLicense`, { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { body: { message: 'test forbidden message' } } }, }); }); describe('success', () => { getPrivilegesTest(`returns registered application privileges with actions when requested`, { includeActions: true, - asserts: { - statusCode: 200, - result: createRawKibanaPrivileges(), - }, + asserts: { statusCode: 200, result: { body: createRawKibanaPrivileges() } }, }); getPrivilegesTest(`returns registered application privileges without actions`, { @@ -115,13 +103,12 @@ describe('GET privileges', () => { asserts: { statusCode: 200, result: { - global: ['all', 'read'], - space: ['all', 'read'], - features: { - feature1: ['all'], - feature2: ['all'], + body: { + global: ['all', 'read'], + space: ['all', 'read'], + features: { feature1: ['all'], feature2: ['all'] }, + reserved: ['customApplication1', 'customApplication2'], }, - reserved: ['customApplication1', 'customApplication2'], }, }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get.ts index 273af1b3f0eb972..81047c7faea9657 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get.ts @@ -3,46 +3,45 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; -import { RawKibanaPrivileges } from '../../../../../common/model'; +import { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../..'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; -export function initGetPrivilegesApi( - server: Record, - routePreCheckLicenseFn: () => void -) { - server.route({ - method: 'GET', - path: '/api/security/privileges', - handler(req: Record) { - const { authorization } = server.plugins.security; - const privileges: RawKibanaPrivileges = authorization.privileges.get(); - - if (req.query.includeActions) { - return privileges; - } - - return { - global: Object.keys(privileges.global), - space: Object.keys(privileges.space), - features: Object.entries(privileges.features).reduce( - (acc, [featureId, featurePrivileges]) => { - return { - ...acc, - [featureId]: Object.keys(featurePrivileges), - }; - }, - {} - ), - reserved: Object.keys(privileges.reserved), - }; - }, - config: { - pre: [routePreCheckLicenseFn], +export function defineGetPrivilegesRoutes({ router, authz }: RouteDefinitionParams) { + router.get( + { + path: '/api/security/privileges', validate: { - query: Joi.object().keys({ - includeActions: Joi.bool(), + query: schema.object({ + // We don't use `schema.boolean` here, because all query string parameters are treated as + // strings and @kbn/config-schema doesn't coerce strings to booleans. + includeActions: schema.maybe( + schema.oneOf([schema.literal('true'), schema.literal('false')]) + ), }), }, }, - }); + createLicensedRouteHandler((context, request, response) => { + const privileges = authz.privileges.get(); + const includeActions = request.query.includeActions === 'true'; + const privilegesResponseBody = includeActions + ? privileges + : { + global: Object.keys(privileges.global), + space: Object.keys(privileges.space), + features: Object.entries(privileges.features).reduce( + (acc, [featureId, featurePrivileges]) => { + return { + ...acc, + [featureId]: Object.keys(featurePrivileges), + }; + }, + {} + ), + reserved: Object.keys(privileges.reserved), + }; + + return response.ok({ body: privilegesResponseBody }); + }) + ); } diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts b/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts index 991b57b11a8f8af..e3071ad0b5c4277 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/get_builtin.ts @@ -4,26 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Legacy } from 'kibana'; import { BuiltinESPrivileges } from '../../../../common/model'; -import { getClient } from '../../../../../../server/lib/get_client_shield'; +import { RouteDefinitionParams } from '../..'; -export function initGetBuiltinPrivilegesApi(server: Legacy.Server) { - server.route({ - method: 'GET', - path: '/api/security/v1/esPrivileges/builtin', - async handler(req: Legacy.Request) { - const callWithRequest = getClient(server).callWithRequest; - const privileges = await callWithRequest( - req, - 'shield.getBuiltinPrivileges' - ); +export function defineGetBuiltinPrivilegesRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.get( + { path: '/api/security/esPrivileges/builtin', validate: false }, + async (context, request, response) => { + const privileges: BuiltinESPrivileges = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getBuiltinPrivileges'); // Exclude the `none` privilege, as it doesn't make sense as an option within the Kibana UI privileges.cluster = privileges.cluster.filter(privilege => privilege !== 'none'); privileges.index = privileges.index.filter(privilege => privilege !== 'none'); - return privileges; - }, - }); + return response.ok({ body: privileges }); + } + ); } diff --git a/x-pack/plugins/security/server/routes/authorization/privileges/index.ts b/x-pack/plugins/security/server/routes/authorization/privileges/index.ts index 2af1f99ef7f54bb..7c7ff402fcee2c0 100644 --- a/x-pack/plugins/security/server/routes/authorization/privileges/index.ts +++ b/x-pack/plugins/security/server/routes/authorization/privileges/index.ts @@ -3,12 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore -import { routePreCheckLicense } from '../../../../lib/route_pre_check_license'; -import { initGetPrivilegesApi } from './get'; -export function initPrivilegesApi(server: Record) { - const routePreCheckLicenseFn = routePreCheckLicense(server); +import { RouteDefinitionParams } from '../..'; +import { defineGetPrivilegesRoutes } from './get'; +import { defineGetBuiltinPrivilegesRoutes } from './get_builtin'; - initGetPrivilegesApi(server, routePreCheckLicenseFn); +export function definePrivilegesRoutes(params: RouteDefinitionParams) { + defineGetPrivilegesRoutes(params); + defineGetBuiltinPrivilegesRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts index 638edf577da3a60..0ec6c309d2affeb 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.test.ts @@ -4,121 +4,111 @@ * you may not use this file except in compliance with the Elastic License. */ -import Hapi from 'hapi'; import Boom from 'boom'; -import { initDeleteRolesApi } from './delete'; +import { RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { defineDeleteRolesRoutes } from './delete'; -const createMockServer = () => { - const mockServer = new Hapi.Server({ debug: false, port: 8080 }); - return mockServer; -}; +import { + elasticsearchServiceMock, + httpServerMock, +} from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; -const defaultPreCheckLicenseImpl = () => null; +interface TestOptions { + licenseCheckResult?: ILicenseCheck; + name: string; + apiResponse?: () => Promise; + asserts: { statusCode: 204 | 403 | 404; result?: Record }; +} describe('DELETE role', () => { const deleteRoleTest = ( - description, + description: string, { name, - preCheckLicenseImpl, - callWithRequestImpl, + licenseCheckResult = { check: LICENSE_STATUS.Valid }, + apiResponse, asserts, - } + }: TestOptions ) => { test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - if (callWithRequestImpl) { - mockCallWithRequest.mockImplementation(callWithRequestImpl); + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + if (apiResponse) { + mockScopedClusterClient.callAsCurrentUser.mockImplementation(apiResponse); } - initDeleteRolesApi(mockServer, mockCallWithRequest, pre); - const headers = { - authorization: 'foo', - }; - const request = { - method: 'DELETE', - url: `/api/security/role/${name}`, + defineDeleteRolesRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.delete.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/api/security/role/${name}`, + params: { name }, headers, - }; - const { result, statusCode } = await mockServer.inject(request); + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; - if (preCheckLicenseImpl) { - expect(pre).toHaveBeenCalled(); + const mockResponseResult = { status: asserts.statusCode, options: {} }; + const mockResponse = httpServerMock.createResponseFactory(); + let mockResponseFactory; + if (asserts.statusCode === 204) { + mockResponseFactory = mockResponse.noContent; + } else if (asserts.statusCode === 403) { + mockResponseFactory = mockResponse.forbidden; } else { - expect(pre).not.toHaveBeenCalled(); + mockResponseFactory = mockResponse.customError; + } + mockResponseFactory.mockReturnValue(mockResponseResult); + + const response = await handler(mockContext, mockRequest, mockResponse); + + expect(response).toBe(mockResponseResult); + expect(mockResponseFactory).toHaveBeenCalledTimes(1); + if (asserts.result !== undefined) { + expect(mockResponseFactory).toHaveBeenCalledWith(asserts.result); } - if (callWithRequestImpl) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), + if (apiResponse) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith( 'shield.deleteRole', - { name }, + { name } ); } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); } - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); }); }; describe('failure', () => { - deleteRoleTest(`requires name in params`, { - name: '', - asserts: { - statusCode: 404, - result: { - error: 'Not Found', - message: 'Not Found', - statusCode: 404, - }, - }, - }); - - deleteRoleTest(`returns result of routePreCheckLicense`, { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, + deleteRoleTest(`returns result of license checker`, { + name: 'foo-role', + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { body: { message: 'test forbidden message' } } }, }); - deleteRoleTest(`returns error from callWithRequest`, { + const error = Boom.notFound('test not found message'); + deleteRoleTest(`returns error from cluster client`, { name: 'foo-role', - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpl: async () => { - throw Boom.notFound('test not found message'); - }, - asserts: { - statusCode: 404, - result: { - error: 'Not Found', - statusCode: 404, - message: 'test not found message', - }, - }, + apiResponse: () => Promise.reject(error), + asserts: { statusCode: 404, result: { body: error, statusCode: 404 } }, }); }); describe('success', () => { deleteRoleTest(`deletes role`, { name: 'foo-role', - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpl: async () => {}, - asserts: { - statusCode: 204, - result: null - } + apiResponse: async () => {}, + asserts: { statusCode: 204, result: undefined }, }); }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/delete.ts b/x-pack/plugins/security/server/routes/authorization/roles/delete.ts index 8568321ba194148..aab815fbe449ff7 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/delete.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/delete.ts @@ -4,30 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -import Joi from 'joi'; -import { wrapError } from '../../../../../../../../plugins/security/server'; +import { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../../index'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; +import { wrapError } from '../../../errors'; -export function initDeleteRolesApi(server, callWithRequest, routePreCheckLicenseFn) { - server.route({ - method: 'DELETE', - path: '/api/security/role/{name}', - handler(request, h) { - const { name } = request.params; - return callWithRequest(request, 'shield.deleteRole', { name }).then( - () => h.response().code(204), - wrapError - ); +export function defineDeleteRolesRoutes({ router, clusterClient }: RouteDefinitionParams) { + router.delete( + { + path: '/api/security/role/{name}', + validate: { params: schema.object({ name: schema.string({ minLength: 1 }) }) }, }, - config: { - validate: { - params: Joi.object() - .keys({ - name: Joi.string() - .required(), - }) - .required(), - }, - pre: [routePreCheckLicenseFn] - } - }); + createLicensedRouteHandler(async (context, request, response) => { + try { + await clusterClient.asScoped(request).callAsCurrentUser('shield.deleteRole', { + name: request.params.name, + }); + + return response.noContent(); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); + } + }) + ); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index 24aa4bd6e02b216..fb02bdb30c1ce85 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -3,87 +3,124 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import Hapi from 'hapi'; import Boom from 'boom'; -import { initGetRolesApi } from './get'; +import { RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { defineGetRolesRoutes } from './get'; + +import { + elasticsearchServiceMock, + httpServerMock, +} from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; const application = 'kibana-.kibana'; const reservedPrivilegesApplicationWildcard = 'kibana-*'; -const createMockServer = () => { - const mockServer = new Hapi.Server({ debug: false, port: 8080 }); - return mockServer; -}; +interface TestOptions { + name?: string; + licenseCheckResult?: ILicenseCheck; + apiResponse?: () => Promise; + asserts: { statusCode: 200 | 403 | 406 | 500; result?: Record }; +} describe('GET roles', () => { const getRolesTest = ( - description, - { - preCheckLicenseImpl = () => null, - callWithRequestImpl, - asserts, - } + description: string, + { licenseCheckResult = { check: LICENSE_STATUS.Valid }, apiResponse, asserts }: TestOptions ) => { test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - if (callWithRequestImpl) { - mockCallWithRequest.mockImplementation(callWithRequestImpl); + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.getApplicationName.mockReturnValue(application); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + if (apiResponse) { + mockScopedClusterClient.callAsCurrentUser.mockImplementation(apiResponse); } - initGetRolesApi(mockServer, mockCallWithRequest, pre, application); - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'GET', - url: '/api/security/role', + + defineGetRolesRoutes(mockRouteDefinitionParams); + const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: '/api/security/role', headers, - }; - const { result, statusCode } = await mockServer.inject(request); - - expect(pre).toHaveBeenCalled(); - if (callWithRequestImpl) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - 'shield.getRole' - ); + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const mockResponseResult = { status: asserts.statusCode, options: {} }; + const mockResponse = httpServerMock.createResponseFactory(); + let mockResponseFactory; + if (asserts.statusCode === 200) { + mockResponseFactory = mockResponse.ok; + } else if (asserts.statusCode === 403) { + mockResponseFactory = mockResponse.forbidden; + } else { + mockResponseFactory = mockResponse.customError; + } + mockResponseFactory.mockReturnValue(mockResponseResult); + + const response = await handler(mockContext, mockRequest, mockResponse); + + expect(response).toBe(mockResponseResult); + expect(mockResponseFactory).toHaveBeenCalledTimes(1); + if (asserts.result !== undefined) { + expect(mockResponseFactory).toHaveBeenCalledWith(asserts.result); + } + + if (apiResponse) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.getRole'); } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); } - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); }); }; describe('failure', () => { - getRolesTest(`returns result of routePreCheckLicense`, { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, + getRolesTest(`returns result of license check`, { + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { body: { message: 'test forbidden message' } } }, }); - getRolesTest(`returns error from callWithRequest`, { - callWithRequestImpl: async () => { - throw Boom.notAcceptable('test not acceptable message'); - }, + const error = Boom.notAcceptable('test not acceptable message'); + getRolesTest(`returns error from cluster client`, { + apiResponse: () => Promise.reject(error), + asserts: { statusCode: 406, result: { body: error, statusCode: 406 } }, + }); + + getRolesTest(`return error if we have empty resources`, { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: [], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), asserts: { - statusCode: 406, + statusCode: 500, result: { - error: 'Not Acceptable', - statusCode: 406, - message: 'test not acceptable message', + body: new Error("ES returned an application entry without resources, can't process this"), + statusCode: 500, }, }, }); @@ -91,7 +128,7 @@ describe('GET roles', () => { describe('success', () => { getRolesTest(`transforms elasticsearch privileges`, { - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: ['manage_watcher'], indices: [ @@ -112,150 +149,426 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: ['manage_watcher'], + indices: [ + { + names: ['.kibana*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + run_as: ['other_user'], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: [], }, - transient_metadata: { - enabled: true, + ], + }, + }, + }); + + describe('global', () => { + getRolesTest( + `transforms matching applications with * resource to kibana global base privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all', 'read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, }, - elasticsearch: { - cluster: ['manage_watcher'], - indices: [ + }), + asserts: { + statusCode: 200, + result: { + body: [ { - names: ['.kibana*'], - privileges: ['read', 'view_index_metadata'], + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: ['all', 'read'], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], }, ], - run_as: ['other_user'], }, - kibana: [], - _transform_error: [], - _unrecognized_applications: [], }, - ], - }, - }); + } + ); - describe('global', () => { - getRolesTest(`transforms matching applications with * resource to kibana global base privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all', 'read'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, + getRolesTest( + `transforms matching applications with * resource to kibana global feature privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + bar: ['bar-privilege-1'], + }, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', + } + ); + + getRolesTest( + `transforms matching applications with * resource to kibana _reserved privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_customApplication1', 'reserved_customApplication2'], + resources: ['*'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ + }, + }), + asserts: { + statusCode: 200, + result: { + body: [ { - base: ['all', 'read'], - feature: {}, - spaces: ['*'] - } + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + _reserved: ['customApplication1', 'customApplication2'], + base: [], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, ], - _transform_error: [], - _unrecognized_applications: [], }, - ], - }, - }); + }, + } + ); - getRolesTest(`transforms matching applications with * resource to kibana global feature privileges`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2', 'feature_bar.bar-privilege-1'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, + getRolesTest( + `transforms applications with wildcard and * resource to kibana _reserved privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['reserved_customApplication1', 'reserved_customApplication2'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + _reserved: ['customApplication1', 'customApplication2'], + base: [], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', + } + ); + }); + + describe('space', () => { + getRolesTest( + `transforms matching applications with space resources to kibana space base privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all', 'space_read'], + resources: ['space:marketing', 'space:sales'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + }, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: ['all', 'read'], + feature: {}, + spaces: ['marketing', 'sales'], + }, + { + base: ['read'], + feature: {}, + spaces: ['engineering'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + ], + }, + }, + } + ); + + getRolesTest( + `transforms matching applications with space resources to kibana space feature privileges`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['space:marketing', 'space:sales'], + }, + { + application, + privileges: ['feature_foo.foo-privilege-1'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, }, - kibana: [ + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + body: [ { - base: [], - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - bar: ['bar-privilege-1'] + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, }, - spaces: ['*'] - } + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + bar: ['bar-privilege-1'], + }, + spaces: ['marketing', 'sales'], + }, + { + base: [], + feature: { + foo: ['foo-privilege-1'], + }, + spaces: ['engineering'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, ], - _transform_error: [], - _unrecognized_applications: [], }, - ], - }, - }); + }, + } + ); + }); - getRolesTest(`transforms matching applications with * resource to kibana _reserved privileges`, { - callWithRequestImpl: async () => ({ + getRolesTest( + `resource not * without space: prefix returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { application, - privileges: ['reserved_customApplication1', 'reserved_customApplication2'], - resources: ['*'], - } + privileges: ['read'], + resources: ['default'], + }, ], run_as: [], metadata: { @@ -268,46 +581,44 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [ - { - _reserved: ['customApplication1', 'customApplication2'], - base: [], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], + ], + }, }, - }); + } + ); - getRolesTest(`transforms applications with wildcard and * resource to kibana _reserved privileges`, { - callWithRequestImpl: async () => ({ + getRolesTest( + `* and a space in the same entry returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { - application: reservedPrivilegesApplicationWildcard, - privileges: ['reserved_customApplication1', 'reserved_customApplication2'], - resources: ['*'], - } + application, + privileges: ['all'], + resources: ['*', 'space:engineering'], + }, ], run_as: [], metadata: { @@ -320,52 +631,48 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [ - { - _reserved: ['customApplication1', 'customApplication2'], - base: [], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], + ], + }, }, - }); - }); + } + ); - describe('space', () => { - getRolesTest(`transforms matching applications with space resources to kibana space base privileges`, { - callWithRequestImpl: async () => ({ + getRolesTest( + `* appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { application, - privileges: ['space_all', 'space_read'], - resources: ['space:marketing', 'space:sales'], + privileges: ['all'], + resources: ['*'], }, { application, - privileges: ['space_read'], - resources: ['space:engineering'], + privileges: ['read'], + resources: ['*'], }, ], run_as: [], @@ -379,53 +686,47 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: ['all', 'read'], - feature: {}, - spaces: ['marketing', 'sales'], + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, }, - { - base: ['read'], - feature: {}, - spaces: ['engineering'], + transient_metadata: { + enabled: true, }, - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], + }, }, - }); + } + ); - getRolesTest(`transforms matching applications with space resources to kibana space feature privileges`, { - callWithRequestImpl: async () => ({ + getRolesTest( + `space appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { application, - privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2', 'feature_bar.bar-privilege-1'], - resources: ['space:marketing', 'space:sales'], + privileges: ['space_all'], + resources: ['space:engineering'], }, { application, - privileges: ['feature_foo.foo-privilege-1'], + privileges: ['space_read'], resources: ['space:engineering'], }, ], @@ -440,520 +741,361 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: [], - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - bar: ['bar-privilege-1'] - }, - spaces: ['marketing', 'sales'], + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, }, - { - base: [], - feature: { - foo: ['foo-privilege-1'], - }, - spaces: ['engineering'] - } - ], - _transform_error: [], - _unrecognized_applications: [], - }, - ], - }, - }); - }); - - getRolesTest(`return error if we have empty resources`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: [], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 500, - result: { - error: 'Internal Server Error', - statusCode: 500, - message: 'An internal server error occurred', - }, - }, - }); + } + ); - getRolesTest(`resource not * without space: prefix returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: ['default'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', + getRolesTest( + `space privilege assigned globally returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all'], + resources: ['*'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`* and a space in the same entry returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all'], - resources: ['*', 'space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', + } + ); + + getRolesTest( + `space privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], }, - ], - }, - }); - - getRolesTest(`* appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all'], - resources: ['*'], - }, - { - application, - privileges: ['read'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', + } + ); + + getRolesTest( + `global base privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['all'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`space appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all'], - resources: ['space:engineering'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', + } + ); + + getRolesTest( + `global base privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['all'], + resources: ['*'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`space privilege assigned globally returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all'], - resources: ['*'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', + } + ); + + getRolesTest( + `reserved privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`space privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', + } + ); + + getRolesTest( + `reserved privilege assigned with a base privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo', 'read'], + resources: ['*'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`global base privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all'], - resources: ['space:marketing'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + ], }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`global base privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['all'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest(`reserved privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo'], - resources: ['space:marketing'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest( - `reserved privilege assigned with a base privilege returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo', 'read'], - resources: ['*'], - } - ], - run_as: [], + } + ); + + getRolesTest( + `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], + resources: ['*'], + }, + ], + run_as: [], metadata: { _reserved: true, }, @@ -964,77 +1106,35 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], - }, - }); - - getRolesTest( - `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ + result: { + body: [ { - application, - privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], - resources: ['*'], - } + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, }, - }), - asserts: { - statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], }, - }); + } + ); getRolesTest( - `global base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ + `global base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ first_role: { cluster: [], indices: [], @@ -1043,7 +1143,7 @@ describe('GET roles', () => { application, privileges: ['all', 'feature_foo.foo-privilege-1'], resources: ['*'], - } + }, ], run_as: [], metadata: { @@ -1056,31 +1156,35 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], + ], + }, }, - }); + } + ); getRolesTest( - `space base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { - callWithRequestImpl: async () => ({ + `space base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + apiResponse: async () => ({ first_role: { cluster: [], indices: [], @@ -1089,7 +1193,7 @@ describe('GET roles', () => { application, privileges: ['space_all', 'feature_foo.foo-privilege-1'], resources: ['space:space_1'], - } + }, ], run_as: [], metadata: { @@ -1102,30 +1206,33 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - ], + ], + }, }, - }); + } + ); getRolesTest(`transforms unrecognized applications`, { - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], @@ -1147,30 +1254,32 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + result: { + body: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - ], + ], + }, }, }); getRolesTest(`returns a sorted list of roles`, { - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ z_role: { cluster: [], indices: [], @@ -1228,59 +1337,61 @@ describe('GET roles', () => { }), asserts: { statusCode: 200, - result: [ - { - name: 'a_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - { - name: 'b_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - { - name: 'z_role', - metadata: { - _reserved: true, + result: { + body: [ + { + name: 'a_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], }, - transient_metadata: { - enabled: true, + { + name: 'b_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + { + name: 'z_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] - }, - ], + ], + }, }, }); }); @@ -1288,83 +1399,86 @@ describe('GET roles', () => { describe('GET role', () => { const getRoleTest = ( - description, + description: string, { name, - preCheckLicenseImpl = () => null, - callWithRequestImpl, + licenseCheckResult = { check: LICENSE_STATUS.Valid }, + apiResponse, asserts, - } + }: TestOptions ) => { test(description, async () => { - const mockServer = createMockServer(); - const pre = jest.fn().mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - if (callWithRequestImpl) { - mockCallWithRequest.mockImplementation(callWithRequestImpl); + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.getApplicationName.mockReturnValue(application); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + if (apiResponse) { + mockScopedClusterClient.callAsCurrentUser.mockImplementation(apiResponse); } - initGetRolesApi(mockServer, mockCallWithRequest, pre, 'kibana-.kibana'); - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'GET', - url: `/api/security/role/${name}`, + + defineGetRolesRoutes(mockRouteDefinitionParams); + const [, [, handler]] = mockRouteDefinitionParams.router.get.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'delete', + path: `/api/security/role/${name}`, + params: { name }, headers, - }; - const { result, statusCode } = await mockServer.inject(request); - - expect(pre).toHaveBeenCalled(); - if (callWithRequestImpl) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - 'shield.getRole', - { name } - ); + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const mockResponseResult = { status: asserts.statusCode, options: {} }; + const mockResponse = httpServerMock.createResponseFactory(); + let mockResponseFactory; + if (asserts.statusCode === 200) { + mockResponseFactory = mockResponse.ok; + } else if (asserts.statusCode === 403) { + mockResponseFactory = mockResponse.forbidden; + } else { + mockResponseFactory = mockResponse.customError; + } + mockResponseFactory.mockReturnValue(mockResponseResult); + + const response = await handler(mockContext, mockRequest, mockResponse); + + expect(response).toBe(mockResponseResult); + expect(mockResponseFactory).toHaveBeenCalledTimes(1); + if (asserts.result !== undefined) { + expect(mockResponseFactory).toHaveBeenCalledWith(asserts.result); + } + + if (apiResponse) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith('shield.getRole', { + name, + }); } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); } - expect(statusCode).toBe(asserts.statusCode); - expect(result).toEqual(asserts.result); + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); }); }; describe('failure', () => { - getRoleTest(`returns result of routePreCheckLicense`, { - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, + getRoleTest(`returns result of license check`, { + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { body: { message: 'test forbidden message' } } }, }); - getRoleTest(`returns error from callWithRequest`, { + const error = Boom.notAcceptable('test not acceptable message'); + getRoleTest(`returns error from cluster client`, { name: 'first_role', - callWithRequestImpl: async () => { - throw Boom.notAcceptable('test not acceptable message'); - }, - asserts: { - statusCode: 406, - result: { - error: 'Not Acceptable', - statusCode: 406, - message: 'test not acceptable message', - }, - }, + apiResponse: () => Promise.reject(error), + asserts: { statusCode: 406, result: { body: error, statusCode: 406 } }, }); getRoleTest(`return error if we have empty resources`, { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], @@ -1387,9 +1501,8 @@ describe('GET role', () => { asserts: { statusCode: 500, result: { - error: 'Internal Server Error', + body: new Error("ES returned an application entry without resources, can't process this"), statusCode: 500, - message: 'An internal server error occurred', }, }, }); @@ -1398,7 +1511,7 @@ describe('GET role', () => { describe('success', () => { getRoleTest(`transforms elasticsearch privileges`, { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: ['manage_watcher'], indices: [ @@ -1420,56 +1533,7 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: ['manage_watcher'], - indices: [ - { - names: ['.kibana*'], - privileges: ['read', 'view_index_metadata'], - }, - ], - run_as: ['other_user'], - }, - kibana: [], - _transform_error: [], - _unrecognized_applications: [], - }, - }, - }); - - describe('global', () => { - getRoleTest(`transforms matching applications with * resource to kibana global base privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all', 'read'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { + body: { name: 'first_role', metadata: { _reserved: true, @@ -1478,139 +1542,409 @@ describe('GET role', () => { enabled: true, }, elasticsearch: { - cluster: [], - indices: [], - run_as: [], + cluster: ['manage_watcher'], + indices: [ + { + names: ['.kibana*'], + privileges: ['read', 'view_index_metadata'], + }, + ], + run_as: ['other_user'], }, - kibana: [ - { - base: ['all', 'read'], - feature: {}, - spaces: ['*'] - } - ], + kibana: [], _transform_error: [], _unrecognized_applications: [], }, }, - }); + }, + }); - getRoleTest(`transforms matching applications with * resource to kibana global feature privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2', 'feature_bar.bar-privilege-1'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { + describe('global', () => { + getRoleTest( + `transforms matching applications with * resource to kibana global base privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { cluster: [], indices: [], + applications: [ + { + application, + privileges: ['all', 'read'], + resources: ['*'], + }, + ], run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, }, - kibana: [ - { - base: [], - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - bar: ['bar-privilege-1'] + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, }, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: ['all', 'read'], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, }, - }, - }); + } + ); - getRoleTest(`transforms matching applications with * resource to kibana _reserved privileges`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_customApplication1', 'reserved_customApplication2'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, + getRoleTest( + `transforms matching applications with * resource to kibana global feature privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, }, - transient_metadata: { - enabled: true, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + bar: ['bar-privilege-1'], + }, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, }, }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { + } + ); + + getRoleTest( + `transforms matching applications with * resource to kibana _reserved privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { cluster: [], indices: [], - run_as: [], + applications: [ + { + application, + privileges: ['reserved_customApplication1', 'reserved_customApplication2'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + _reserved: ['customApplication1', 'customApplication2'], + base: [], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, }, - kibana: [ - { - _reserved: ['customApplication1', 'customApplication2'], - base: [], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], }, - }, - }); + } + ); + + getRoleTest( + `transforms applications with wildcard and * resource to kibana _reserved privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['reserved_customApplication1', 'reserved_customApplication2'], + resources: ['*'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + _reserved: ['customApplication1', 'customApplication2'], + base: [], + feature: {}, + spaces: ['*'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + }, + } + ); + }); + + describe('space', () => { + getRoleTest( + `transforms matching applications with space resources to kibana space base privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['space_all', 'space_read'], + resources: ['space:marketing', 'space:sales'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: ['all', 'read'], + feature: {}, + spaces: ['marketing', 'sales'], + }, + { + base: ['read'], + feature: {}, + spaces: ['engineering'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + }, + } + ); + + getRoleTest( + `transforms matching applications with space resources to kibana space feature privileges`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['space:marketing', 'space:sales'], + }, + { + application, + privileges: ['feature_foo.foo-privilege-1'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + foo: ['foo-privilege-1', 'foo-privilege-2'], + bar: ['bar-privilege-1'], + }, + spaces: ['marketing', 'sales'], + }, + { + base: [], + feature: { + foo: ['foo-privilege-1'], + }, + spaces: ['engineering'], + }, + ], + _transform_error: [], + _unrecognized_applications: [], + }, + }, + }, + } + ); + }); - getRoleTest(`transforms applications with wildcard and * resource to kibana _reserved privileges`, { + getRoleTest( + `resource not * without space: prefix returns empty kibana section with _transform_error set to ['kibana']`, + { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { - application: reservedPrivilegesApplicationWildcard, - privileges: ['reserved_customApplication1', 'reserved_customApplication2'], - resources: ['*'], - } + application, + privileges: ['read'], + resources: ['default'], + }, ], run_as: [], metadata: { @@ -1624,45 +1958,90 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + }, + } + ); + + getRoleTest( + `* and a space in the same entry returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: ['default'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + }, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [ - { - _reserved: ['customApplication1', 'customApplication2'], - base: [], - feature: {}, - spaces: ['*'] - } - ], - _transform_error: [], - _unrecognized_applications: [], }, }, - }); - }); + } + ); - describe('space', () => { - getRoleTest(`transforms matching applications with space resources to kibana space base privileges`, { + getRoleTest( + `* appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, + { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { application, - privileges: ['space_all', 'space_read'], - resources: ['space:marketing', 'space:sales'], + privileges: ['space_all'], + resources: ['space:engineering'], }, { application, @@ -1682,51 +2061,45 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: ['all', 'read'], - feature: {}, - spaces: ['marketing', 'sales'], + body: { + name: 'first_role', + metadata: { + _reserved: true, }, - { - base: ['read'], - feature: {}, - spaces: ['engineering'], + transient_metadata: { + enabled: true, }, - ], - _transform_error: [], - _unrecognized_applications: [], + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, }, }, - }); + } + ); - getRoleTest(`transforms matching applications with space resources to kibana space feature privileges`, { + getRoleTest( + `space privilege assigned globally returns empty kibana section with _transform_error set to ['kibana']`, + { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { application, - privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2', 'feature_bar.bar-privilege-1'], - resources: ['space:marketing', 'space:sales'], + privileges: ['space_all'], + resources: ['*'], }, { application, - privileges: ['feature_foo.foo-privilege-1'], + privileges: ['space_read'], resources: ['space:engineering'], }, ], @@ -1742,428 +2115,145 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + }, + } + ); + + getRoleTest( + `space privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application: reservedPrivilegesApplicationWildcard, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - base: [], - feature: { - foo: ['foo-privilege-1', 'foo-privilege-2'], - bar: ['bar-privilege-1'] - }, - spaces: ['marketing', 'sales'], - }, - { - base: [], - feature: { - foo: ['foo-privilege-1'], - }, - spaces: ['engineering'] - } - ], - _transform_error: [], - _unrecognized_applications: [], }, - }, - }); - }); - - getRoleTest(`resource not * without space: prefix returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: ['default'], + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, }, }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, } - }); + ); - getRoleTest(`* and a space in the same entry returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['read'], - resources: ['default'], - }, - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`* appearing in multiple entries returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all'], - resources: ['space:engineering'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`space privilege assigned globally returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['space_all'], - resources: ['*'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`space privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`global base privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['all'], - resources: ['space:marketing'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - getRoleTest(`global base privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application: reservedPrivilegesApplicationWildcard, - privileges: ['all'], - resources: ['*'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], - }, - }, - }); - - - getRoleTest(`reserved privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, { - name: 'first_role', - callWithRequestImpl: async () => ({ - first_role: { - cluster: [], - indices: [], - applications: [ - { - application, - privileges: ['reserved_foo'], - resources: ['space:marketing'], - }, - { - application, - privileges: ['space_read'], - resources: ['space:engineering'], - } - ], - run_as: [], - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - }, - }), - asserts: { - statusCode: 200, - result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { + getRoleTest( + `global base privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { cluster: [], indices: [], + applications: [ + { + application, + privileges: ['all'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], }, - }, - }); + } + ); getRoleTest( - `reserved privilege assigned with a base privilege returns empty kibana section with _transform_error set to ['kibana']`, { + `global base privilege with application wildcard returns empty kibana section with _transform_error set to ['kibana']`, + { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { - application, - privileges: ['reserved_foo', 'read'], + application: reservedPrivilegesApplicationWildcard, + privileges: ['all'], resources: ['*'], - } + }, ], run_as: [], metadata: { @@ -2177,38 +2267,96 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + }, + } + ); + + getRoleTest( + `reserved privilege assigned at a space returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + }, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], }, }, - }); + } + ); getRoleTest( - `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { + `reserved privilege assigned with a base privilege returns empty kibana section with _transform_error set to ['kibana']`, + { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], applications: [ { application, - privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], + privileges: ['reserved_foo', 'read'], resources: ['*'], - } + }, ], run_as: [], metadata: { @@ -2222,29 +2370,82 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], + }, + }, + }, + } + ); + + getRoleTest( + `reserved privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { + name: 'first_role', + apiResponse: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['reserved_foo', 'feature_foo.foo-privilege-1'], + resources: ['*'], + }, + ], + run_as: [], metadata: { _reserved: true, }, transient_metadata: { enabled: true, }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + }, + }), + asserts: { + statusCode: 200, + result: { + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], }, }, - }); + } + ); getRoleTest( - `global base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { + `global base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], @@ -2253,7 +2454,7 @@ describe('GET role', () => { application, privileges: ['all', 'feature_foo.foo-privilege-1'], resources: ['*'], - } + }, ], run_as: [], metadata: { @@ -2267,29 +2468,33 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], }, }, - }); + } + ); getRoleTest( - `space base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, { + `space base privilege assigned with a feature privilege returns empty kibana section with _transform_error set to ['kibana']`, + { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], @@ -2298,7 +2503,7 @@ describe('GET role', () => { application, privileges: ['space_all', 'feature_foo.foo-privilege-1'], resources: ['space:space_1'], - } + }, ], run_as: [], metadata: { @@ -2312,28 +2517,31 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: ['kibana'], + _unrecognized_applications: [], }, - kibana: [], - _transform_error: ['kibana'], - _unrecognized_applications: [], }, }, - }); + } + ); getRoleTest(`transforms unrecognized applications`, { name: 'first_role', - callWithRequestImpl: async () => ({ + apiResponse: async () => ({ first_role: { cluster: [], indices: [], @@ -2356,21 +2564,23 @@ describe('GET role', () => { asserts: { statusCode: 200, result: { - name: 'first_role', - metadata: { - _reserved: true, - }, - transient_metadata: { - enabled: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], + body: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + _transform_error: [], + _unrecognized_applications: ['kibana-.another-kibana'], }, - kibana: [], - _transform_error: [], - _unrecognized_applications: ['kibana-.another-kibana'] }, }, }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index 3540d9b7a883bf0..5284bf0252f1665 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -3,20 +3,33 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import Boom from 'boom'; -import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD } from '../../../../../common/constants'; -import { wrapError } from '../../../../../../../../plugins/security/server'; -import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization'; - -export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application) { - - const transformKibanaApplicationsFromEs = (roleApplications) => { - const roleKibanaApplications = roleApplications - .filter( - roleApplication => roleApplication.application === application || + +import { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../..'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; +import { + GLOBAL_RESOURCE, + RESERVED_PRIVILEGES_APPLICATION_WILDCARD, +} from '../../../../common/constants'; +import { Role, RoleKibanaPrivilege } from '../../../../common/model'; +import { wrapError } from '../../../errors'; +import { PrivilegeSerializer } from '../../../authorization/privilege_serializer'; +import { ResourceSerializer } from '../../../authorization/resource_serializer'; +import { ElasticsearchRole } from '.'; + +function getUniqueList(list: T[]) { + return Array.from(new Set(list)); +} + +export function defineGetRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) { + const transformKibanaApplicationsToRolePrivileges = ( + roleApplications: ElasticsearchRole['applications'] + ) => { + const roleKibanaApplications = roleApplications.filter( + roleApplication => + roleApplication.application === authz.getApplicationName() || roleApplication.application === RESERVED_PRIVILEGES_APPLICATION_WILDCARD - ); + ); // if any application entry contains an empty resource, we throw an error if (roleKibanaApplications.some(entry => entry.resources.length === 0)) { @@ -25,198 +38,296 @@ export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, // if there is an entry with the reserved privileges application wildcard // and there are privileges which aren't reserved, we won't transform these - if (roleKibanaApplications.some(entry => - entry.application === RESERVED_PRIVILEGES_APPLICATION_WILDCARD && - !entry.privileges.every(privilege => PrivilegeSerializer.isSerializedReservedPrivilege(privilege))) + if ( + roleKibanaApplications.some( + entry => + entry.application === RESERVED_PRIVILEGES_APPLICATION_WILDCARD && + !entry.privileges.every(privilege => + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ) + ) ) { return { - success: false + success: false, }; } // if space privilege assigned globally, we can't transform these - if (roleKibanaApplications.some(entry => - entry.resources.includes(GLOBAL_RESOURCE) && - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege))) + if ( + roleKibanaApplications.some( + entry => + entry.resources.includes(GLOBAL_RESOURCE) && + entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) + ) + ) ) { return { - success: false + success: false, }; } // if global base or reserved privilege assigned at a space, we can't transform these - if (roleKibanaApplications.some(entry => - !entry.resources.includes(GLOBAL_RESOURCE) && - entry.privileges.some(privilege => - PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) || - PrivilegeSerializer.isSerializedReservedPrivilege(privilege) - )) + if ( + roleKibanaApplications.some( + entry => + !entry.resources.includes(GLOBAL_RESOURCE) && + entry.privileges.some( + privilege => + PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) || + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ) + ) ) { return { - success: false + success: false, }; } // if reserved privilege assigned with feature or base privileges, we won't transform these - if (roleKibanaApplications.some(entry => - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedReservedPrivilege(privilege)) && - entry.privileges.some(privilege => !PrivilegeSerializer.isSerializedReservedPrivilege(privilege))) + if ( + roleKibanaApplications.some( + entry => + entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ) && + entry.privileges.some( + privilege => !PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ) + ) ) { return { - success: false + success: false, }; } // if base privilege assigned with feature privileges, we won't transform these - if (roleKibanaApplications.some(entry => - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedFeaturePrivilege(privilege)) && - ( - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege)) || - entry.privileges.some(privilege => PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege)) + if ( + roleKibanaApplications.some( + entry => + entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedFeaturePrivilege(privilege) + ) && + (entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) + ) || + entry.privileges.some(privilege => + PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) + )) ) - )) { + ) { return { - success: false + success: false, }; } // if any application entry contains the '*' resource in addition to another resource, we can't transform these - if (roleKibanaApplications.some(entry => entry.resources.includes(GLOBAL_RESOURCE) && entry.resources.length > 1)) { + if ( + roleKibanaApplications.some( + entry => entry.resources.includes(GLOBAL_RESOURCE) && entry.resources.length > 1 + ) + ) { return { - success: false + success: false, }; } - const allResources = _.flatten(roleKibanaApplications.map(entry => entry.resources)); + const allResources = roleKibanaApplications.map(entry => entry.resources).flat(); // if we have improperly formatted resource entries, we can't transform these - if (allResources.some(resource => resource !== GLOBAL_RESOURCE && !ResourceSerializer.isSerializedSpaceResource(resource))) { + if ( + allResources.some( + resource => + resource !== GLOBAL_RESOURCE && !ResourceSerializer.isSerializedSpaceResource(resource) + ) + ) { return { - success: false + success: false, }; } // if we have resources duplicated in entries, we won't transform these - if (allResources.length !== _.uniq(allResources).length) { + if (allResources.length !== getUniqueList(allResources).length) { return { - success: false + success: false, }; } return { success: true, value: roleKibanaApplications.map(({ resources, privileges }) => { - // if we're dealing with a global entry, which we've ensured above is only possible if it's the only item in the array + // if we're dealing with a global entry, which we've ensured above is only possible if it's the only item in the array if (resources.length === 1 && resources[0] === GLOBAL_RESOURCE) { - const reservedPrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedReservedPrivilege(privilege)); - const basePrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege)); - const featurePrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedFeaturePrivilege(privilege)); + const reservedPrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedReservedPrivilege(privilege) + ); + const basePrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedGlobalBasePrivilege(privilege) + ); + const featurePrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedFeaturePrivilege(privilege) + ); return { - ...reservedPrivileges.length ? { - _reserved: reservedPrivileges.map(privilege => PrivilegeSerializer.deserializeReservedPrivilege(privilege)) - } : {}, - base: basePrivileges.map(privilege => PrivilegeSerializer.serializeGlobalBasePrivilege(privilege)), - feature: featurePrivileges.reduce((acc, privilege) => { - const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); - return { - ...acc, - [featurePrivilege.featureId]: _.uniq([ - ...acc[featurePrivilege.featureId] || [], - featurePrivilege.privilege - ]) - }; - }, {}), - spaces: ['*'] + ...(reservedPrivileges.length + ? { + _reserved: reservedPrivileges.map(privilege => + PrivilegeSerializer.deserializeReservedPrivilege(privilege) + ), + } + : {}), + base: basePrivileges.map(privilege => + PrivilegeSerializer.serializeGlobalBasePrivilege(privilege) + ), + feature: featurePrivileges.reduce( + (acc, privilege) => { + const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); + return { + ...acc, + [featurePrivilege.featureId]: getUniqueList([ + ...(acc[featurePrivilege.featureId] || []), + featurePrivilege.privilege, + ]), + }; + }, + {} as RoleKibanaPrivilege['feature'] + ), + spaces: ['*'], }; } - const basePrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege)); - const featurePrivileges = privileges.filter(privilege => PrivilegeSerializer.isSerializedFeaturePrivilege(privilege)); + const basePrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedSpaceBasePrivilege(privilege) + ); + const featurePrivileges = privileges.filter(privilege => + PrivilegeSerializer.isSerializedFeaturePrivilege(privilege) + ); return { - base: basePrivileges.map(privilege => PrivilegeSerializer.deserializeSpaceBasePrivilege(privilege)), - feature: featurePrivileges.reduce((acc, privilege) => { - const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); - return { - ...acc, - [featurePrivilege.featureId]: _.uniq([ - ...acc[featurePrivilege.featureId] || [], - featurePrivilege.privilege - ]) - }; - }, {}), - spaces: resources.map(resource => ResourceSerializer.deserializeSpaceResource(resource)) + base: basePrivileges.map(privilege => + PrivilegeSerializer.deserializeSpaceBasePrivilege(privilege) + ), + feature: featurePrivileges.reduce( + (acc, privilege) => { + const featurePrivilege = PrivilegeSerializer.deserializeFeaturePrivilege(privilege); + return { + ...acc, + [featurePrivilege.featureId]: getUniqueList([ + ...(acc[featurePrivilege.featureId] || []), + featurePrivilege.privilege, + ]), + }; + }, + {} as RoleKibanaPrivilege['feature'] + ), + spaces: resources.map(resource => ResourceSerializer.deserializeSpaceResource(resource)), }; - }) + }), }; }; - const transformUnrecognizedApplicationsFromEs = (roleApplications) => { - return _.uniq(roleApplications - .filter(roleApplication => - roleApplication.application !== application && - roleApplication.application !== RESERVED_PRIVILEGES_APPLICATION_WILDCARD - ) - .map(roleApplication => roleApplication.application)); + const extractUnrecognizedApplicationNames = ( + roleApplications: ElasticsearchRole['applications'] + ) => { + return getUniqueList( + roleApplications + .filter( + roleApplication => + roleApplication.application !== authz.getApplicationName() && + roleApplication.application !== RESERVED_PRIVILEGES_APPLICATION_WILDCARD + ) + .map(roleApplication => roleApplication.application) + ); }; - const transformRoleFromEs = (role, name) => { - const kibanaTransformResult = transformKibanaApplicationsFromEs(role.applications); + const transformElasticsearchRoleToRole = ( + elasticsearchRole: ElasticsearchRole, + name: string + ): Role => { + const kibanaTransformResult = transformKibanaApplicationsToRolePrivileges( + elasticsearchRole.applications + ); return { name, - metadata: role.metadata, - transient_metadata: role.transient_metadata, + metadata: elasticsearchRole.metadata, + transient_metadata: elasticsearchRole.transient_metadata, elasticsearch: { - cluster: role.cluster, - indices: role.indices, - run_as: role.run_as, + cluster: elasticsearchRole.cluster, + indices: elasticsearchRole.indices, + run_as: elasticsearchRole.run_as, }, - kibana: kibanaTransformResult.success ? kibanaTransformResult.value : [], - _transform_error: [ - ...(kibanaTransformResult.success ? [] : ['kibana']) - ], - _unrecognized_applications: transformUnrecognizedApplicationsFromEs(role.applications), + kibana: kibanaTransformResult.success ? (kibanaTransformResult.value as Role['kibana']) : [], + _transform_error: [...(kibanaTransformResult.success ? [] : ['kibana'])], + _unrecognized_applications: extractUnrecognizedApplicationNames( + elasticsearchRole.applications + ), }; }; - const transformRolesFromEs = (roles) => { - return _.map(roles, (role, name) => transformRoleFromEs(role, name)); + const transformElasticsearchRolesToRoles = ( + elasticSearchRoles: Record + ) => { + return Object.entries(elasticSearchRoles) + .map(([roleName, elasticsearchRole]) => + transformElasticsearchRoleToRole(elasticsearchRole, roleName) + ) + .sort((roleA, roleB) => { + if (roleA.name < roleB.name) { + return -1; + } + + if (roleA.name > roleB.name) { + return 1; + } + + return 0; + }); }; - server.route({ - method: 'GET', - path: '/api/security/role', - async handler(request) { + router.get( + { path: '/api/security/role', validate: false }, + createLicensedRouteHandler(async (context, request, response) => { try { - const response = await callWithRequest(request, 'shield.getRole'); - return _.sortBy(transformRolesFromEs(response), 'name'); + return response.ok({ + body: transformElasticsearchRolesToRoles( + await clusterClient.asScoped(request).callAsCurrentUser('shield.getRole') + ), + }); } catch (error) { - return wrapError(error); + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); } - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); + }) + ); - server.route({ - method: 'GET', - path: '/api/security/role/{name}', - async handler(request) { - const name = request.params.name; + router.get( + { + path: '/api/security/role/{name}', + validate: { params: schema.object({ name: schema.string({ minLength: 1 }) }) }, + }, + createLicensedRouteHandler(async (context, request, response) => { try { - const response = await callWithRequest(request, 'shield.getRole', { name }); - if (response[name]) { - return transformRoleFromEs(response[name], name); + const elasticsearchRoles = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getRole', { name: request.params.name }); + + const elasticsearchRole = elasticsearchRoles[request.params.name]; + if (elasticsearchRole) { + return response.ok({ + body: transformElasticsearchRoleToRole(elasticsearchRole, request.params.name), + }); } - return Boom.notFound(); + return response.notFound(); } catch (error) { - return wrapError(error); + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); } - }, - config: { - pre: [routePreCheckLicenseFn] - } - }); + }) + ); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/index.ts b/x-pack/plugins/security/server/routes/authorization/roles/index.ts index e883e8a6a8631bd..d29ee4af4913f8d 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/index.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/index.ts @@ -4,20 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getClient } from '../../../../../../../server/lib/get_client_shield'; -import { routePreCheckLicense } from '../../../../lib/route_pre_check_license'; -import { initGetRolesApi } from './get'; -import { initDeleteRolesApi } from './delete'; -import { initPutRolesApi } from './put'; +import { RouteDefinitionParams } from '../..'; +import { defineGetRolesRoutes } from './get'; +import { defineDeleteRolesRoutes } from './delete'; +import { definePutRolesRoutes } from './put'; +import { Role } from '../../../../common/model'; -export function initExternalRolesApi(server) { - const callWithRequest = getClient(server).callWithRequest; - const routePreCheckLicenseFn = routePreCheckLicense(server); +export type ElasticsearchRole = Pick & { + applications: Array<{ + application: string; + privileges: string[]; + resources: string[]; + }>; + cluster: Role['elasticsearch']['cluster']; + indices: Role['elasticsearch']['indices']; + run_as: Role['elasticsearch']['run_as']; +}; - const { authorization } = server.plugins.security; - const { application } = authorization; - - initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application); - initPutRolesApi(server, callWithRequest, routePreCheckLicenseFn, authorization, application); - initDeleteRolesApi(server, callWithRequest, routePreCheckLicenseFn); +export function defineRolesRoutes(params: RouteDefinitionParams) { + defineGetRolesRoutes(params); + defineDeleteRolesRoutes(params); + definePutRolesRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index 01016b2b077c337..45581ef552d8da6 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -4,29 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import Hapi from 'hapi'; -import Boom from 'boom'; -import { initPutRolesApi } from './put'; -import { defaultValidationErrorHandler } from '../../../../../../../../../src/core/server/http/http_tools'; -import { GLOBAL_RESOURCE } from '../../../../../common/constants'; +import { Type } from '@kbn/config-schema'; +import { RequestHandlerContext } from '../../../../../../../src/core/server'; +import { ILicenseCheck } from '../../../../../licensing/server'; +import { LICENSE_STATUS } from '../../../../../licensing/server/constants'; +import { GLOBAL_RESOURCE } from '../../../../common/constants'; +import { definePutRolesRoutes } from './put'; -const application = 'kibana-.kibana'; - -const createMockServer = () => { - const mockServer = new Hapi.Server({ - debug: false, - port: 8080, - routes: { - validate: { - failAction: defaultValidationErrorHandler - } - } - }); - return mockServer; -}; - -const defaultPreCheckLicenseImpl = () => null; +import { + elasticsearchServiceMock, + httpServerMock, +} from '../../../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from '../../index.mock'; +const application = 'kibana-.kibana'; const privilegeMap = { global: { all: [], @@ -44,418 +35,363 @@ const privilegeMap = { bar: { 'bar-privilege-1': [], 'bar-privilege-2': [], - } + }, }, reserved: { customApplication1: [], customApplication2: [], - } + }, }; +interface TestOptions { + name: string; + licenseCheckResult?: ILicenseCheck; + apiResponses?: Array<() => Promise>; + payload?: Record; + asserts: { statusCode: 204 | 403; result?: Record; apiArguments?: unknown[][] }; +} + const putRoleTest = ( - description, - { name, payload, preCheckLicenseImpl, callWithRequestImpls = [], asserts } + description: string, + { + name, + payload, + licenseCheckResult = { check: LICENSE_STATUS.Valid }, + apiResponses = [], + asserts, + }: TestOptions ) => { test(description, async () => { - const mockServer = createMockServer(); - const mockPreCheckLicense = jest - .fn() - .mockImplementation(preCheckLicenseImpl); - const mockCallWithRequest = jest.fn(); - for (const impl of callWithRequestImpls) { - mockCallWithRequest.mockImplementationOnce(impl); + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.getApplicationName.mockReturnValue(application); + mockRouteDefinitionParams.authz.privileges.get.mockReturnValue(privilegeMap); + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockRouteDefinitionParams.clusterClient.asScoped.mockReturnValue(mockScopedClusterClient); + for (const apiResponse of apiResponses) { + mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse); } - const mockAuthorization = { - privileges: { - get: () => privilegeMap - } - }; - initPutRolesApi( - mockServer, - mockCallWithRequest, - mockPreCheckLicense, - mockAuthorization, - application, - ); - const headers = { - authorization: 'foo', - }; - - const request = { - method: 'PUT', - url: `/api/security/role/${name}`, + + definePutRolesRoutes(mockRouteDefinitionParams); + const [[{ validate }, handler]] = mockRouteDefinitionParams.router.put.mock.calls; + + const headers = { authorization: 'foo' }; + const mockRequest = httpServerMock.createKibanaRequest({ + method: 'put', + path: `/api/security/role/${name}`, + params: { name }, + body: payload !== undefined ? (validate as any).body.validate(payload) : undefined, headers, - payload, - }; - const response = await mockServer.inject(request); - const { result, statusCode } = response; - - expect(result).toEqual(asserts.result); - expect(statusCode).toBe(asserts.statusCode); - if (preCheckLicenseImpl) { - expect(mockPreCheckLicense).toHaveBeenCalled(); + }); + const mockContext = ({ + licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } }, + } as unknown) as RequestHandlerContext; + + const mockResponseResult = { status: asserts.statusCode, options: {} }; + const mockResponse = httpServerMock.createResponseFactory(); + let mockResponseFactory; + if (asserts.statusCode === 204) { + mockResponseFactory = mockResponse.noContent; + } else if (asserts.statusCode === 403) { + mockResponseFactory = mockResponse.forbidden; } else { - expect(mockPreCheckLicense).not.toHaveBeenCalled(); + mockResponseFactory = mockResponse.customError; } - if (asserts.callWithRequests) { - for (const args of asserts.callWithRequests) { - expect(mockCallWithRequest).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - authorization: headers.authorization, - }), - }), - ...args - ); + mockResponseFactory.mockReturnValue(mockResponseResult); + + const response = await handler(mockContext, mockRequest, mockResponse); + + expect(response).toBe(mockResponseResult); + expect(mockResponseFactory).toHaveBeenCalledTimes(1); + if (asserts.result !== undefined) { + expect(mockResponseFactory).toHaveBeenCalledWith(asserts.result); + } + + if (Array.isArray(asserts.apiArguments)) { + for (const apiArguments of asserts.apiArguments) { + expect(mockRouteDefinitionParams.clusterClient.asScoped).toHaveBeenCalledWith(mockRequest); + expect(mockScopedClusterClient.callAsCurrentUser).toHaveBeenCalledWith(...apiArguments); } } else { - expect(mockCallWithRequest).not.toHaveBeenCalled(); + expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); } + expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); }); }; describe('PUT role', () => { - describe('failure', () => { - putRoleTest(`requires name in params`, { - name: '', - payload: {}, - asserts: { - statusCode: 404, - result: { - error: 'Not Found', - message: 'Not Found', - statusCode: 404, - }, - }, + describe('request validation', () => { + let requestBodySchema: Type; + let requestParamsSchema: Type; + beforeEach(() => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + mockRouteDefinitionParams.authz.privileges.get.mockReturnValue(privilegeMap); + definePutRolesRoutes(mockRouteDefinitionParams); + + const [[{ validate }]] = mockRouteDefinitionParams.router.put.mock.calls; + requestBodySchema = (validate as any).body; + requestParamsSchema = (validate as any).params; }); - putRoleTest(`requires name in params to not exceed 1024 characters`, { - name: 'a'.repeat(1025), - payload: {}, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - message: `child "name" fails because ["name" length must be less than or equal to 1024 characters long]`, - statusCode: 400, - validation: { - keys: ['name'], - source: 'params', - }, - }, - }, + test('requires name in params', () => { + expect(() => + requestParamsSchema.validate({}, {}, 'request params') + ).toThrowErrorMatchingInlineSnapshot( + `"[request params.name]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + requestParamsSchema.validate({ name: '' }, {}, 'request params') + ).toThrowErrorMatchingInlineSnapshot( + `"[request params.name]: value is [] but it must have a minimum length of [1]."` + ); }); - putRoleTest(`only allows features that match the pattern`, { - name: 'foo-role', - payload: { - kibana: [ + test('requires name in params to not exceed 1024 characters', () => { + expect(() => + requestParamsSchema.validate({ name: 'a'.repeat(1025) }, {}, 'request params') + ).toThrowErrorMatchingInlineSnapshot( + `"[request params.name]: value is [aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa] but it must have a maximum length of [1024]."` + ); + }); + + test('only allows features that match the pattern', () => { + expect(() => + requestBodySchema.validate( { - feature: { - '!foo': ['foo'] - } - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"feature\" fails because [\"!foo\" is not allowed]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.feature.!foo'], - source: 'payload', + kibana: [ + { + feature: { + '!foo': ['foo'], + }, + }, + ], }, - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.kibana.0.feature.key(\\"!foo\\")]: only a-z, A-Z, 0-9, '_', and '-' are allowed"` + ); }); - putRoleTest(`only allows feature privileges that match the pattern`, { - name: 'foo-role', - payload: { - kibana: [ + test('only allows feature privileges that match the pattern', () => { + expect(() => + requestBodySchema.validate( { - feature: { - foo: ['!foo'] - } - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"feature\" fails because [child \"foo\" fails because [\"foo\" at position 0 fails because [\"0\" with value \"!foo\" fails to match the required pattern: /^[a-zA-Z0-9_-]+$/]]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.feature.foo.0'], - source: 'payload', + kibana: [ + { + feature: { + foo: ['!foo'], + }, + }, + ], }, - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.kibana.0.feature.foo]: only a-z, A-Z, 0-9, '_', and '-' are allowed"` + ); }); - putRoleTest(`doesn't allow both base and feature in the same entry`, { - name: 'foo-role', - payload: { - kibana: [ + test(`doesn't allow both base and feature in the same entry`, () => { + expect(() => + requestBodySchema.validate( { - base: ['all'], - feature: { - foo: ['foo'] - } - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [\"base\" conflict with forbidden peer \"feature\"]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.base'], - source: 'payload', + kibana: [ + { + base: ['all'], + feature: { + foo: ['foo'], + }, + }, + ], }, - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.kibana.0]: definition of [feature] isn't allowed when non-empty [base] is defined."` + ); }); describe('global', () => { - putRoleTest(`only allows known Kibana global base privileges`, { - name: 'foo-role', - payload: { - kibana: [ + test(`only allows known Kibana global base privileges`, () => { + expect(() => + requestBodySchema.validate( { - base: ['foo'], - spaces: ['*'] - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"base\" fails because [\"base\" at position 0 fails because [\"0\" must be one of [all, read]]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.base.0'], - source: 'payload', + kibana: [ + { + base: ['foo'], + spaces: ['*'], + }, + ], }, - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.kibana.0.base.0]: unknown global privilege \\"foo\\", must be one of [all,read]"` + ); }); - putRoleTest(`doesn't allow Kibana reserved privileges`, { - name: 'foo-role', - payload: { - kibana: [ + test(`doesn't allow Kibana reserved privileges`, () => { + expect(() => + requestBodySchema.validate( { - _reserved: ['customApplication1'], - spaces: ['*'] - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [\"_reserved\" is not allowed]]`, - statusCode: 400, - validation: { - keys: ['kibana.0._reserved'], - source: 'payload', + kibana: [ + { + _reserved: ['customApplication1'], + spaces: ['*'], + }, + ], }, - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.kibana.0._reserved]: definition for this key is missing"` + ); }); - putRoleTest(`only allows one global entry`, { - name: 'foo-role', - payload: { - kibana: [ + test(`only allows one global entry`, () => { + expect(() => + requestBodySchema.validate( { - feature: { - foo: ['foo-privilege-1'] - }, - spaces: ['*'] + kibana: [ + { + feature: { + foo: ['foo-privilege-1'], + }, + spaces: ['*'], + }, + { + feature: { + bar: ['bar-privilege-1'], + }, + spaces: ['*'], + }, + ], }, - { - feature: { - bar: ['bar-privilege-1'] - }, - spaces: ['*'] - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" position 1 contains a duplicate value]`, - statusCode: 400, - validation: { - keys: ['kibana.1'], - source: 'payload' - } - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot(`"[request body.kibana]: values are not unique"`); }); }); describe('space', () => { - - putRoleTest(`doesn't allow * in a space ID`, { - name: 'foo-role', - payload: { - kibana: [ + test(`doesn't allow * in a space ID`, () => { + expect(() => + requestBodySchema.validate( { - spaces: ['foo-*'] - } - ], - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"spaces\" fails because [\"spaces\" at position 0 fails because [\"0\" must be one of [*]], \"spaces\" at position 0 fails because [\"0\" with value \"foo-*\" fails to match the required pattern: /^[a-z0-9_-]+$/]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.spaces.0', 'kibana.0.spaces.0'], - source: 'payload', + kibana: [ + { + spaces: ['foo-*'], + }, + ], }, - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot(` +"[request body.kibana.0.spaces]: types that failed validation: +- [request body.kibana.0.spaces.0.0]: expected value to equal [*] but got [foo-*] +- [request body.kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed" +`); }); - putRoleTest(`can't assign space and global in same entry`, { - name: 'foo-role', - payload: { - kibana: [ + test(`can't assign space and global in same entry`, () => { + expect(() => + requestBodySchema.validate( { - spaces: ['*', 'foo-space'] - } - ], - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"spaces\" fails because [\"spaces\" at position 1 fails because [\"1\" must be one of [*]], \"spaces\" at position 0 fails because [\"0\" with value \"*\" fails to match the required pattern: /^[a-z0-9_-]+$/]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.spaces.1', 'kibana.0.spaces.0'], - source: 'payload', + kibana: [ + { + spaces: ['*', 'foo-space'], + }, + ], }, - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot(` +"[request body.kibana.0.spaces]: types that failed validation: +- [request body.kibana.0.spaces.0.1]: expected value to equal [*] but got [foo-space] +- [request body.kibana.0.spaces.1.0]: must be lower case, a-z, 0-9, '_', and '-' are allowed" +`); }); - putRoleTest(`only allows known Kibana space base privileges`, { - name: 'foo-role', - payload: { - kibana: [ + test(`only allows known Kibana space base privileges`, () => { + expect(() => + requestBodySchema.validate( { - base: ['foo'], - spaces: ['foo-space'] - } - ], - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [child \"base\" fails because [\"base\" at position 0 fails because [\"0\" must be one of [all, read]]]]]`, - statusCode: 400, - validation: { - keys: ['kibana.0.base.0'], - source: 'payload', + kibana: [ + { + base: ['foo'], + spaces: ['foo-space'], + }, + ], }, - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.kibana.0.base.0]: unknown space privilege \\"foo\\", must be one of [all,read]"` + ); }); - putRoleTest(`only allows space to be in one entry`, { - name: 'foo-role', - payload: { - kibana: [ + test(`only allows space to be in one entry`, () => { + expect(() => + requestBodySchema.validate( { - feature: { - foo: ['foo-privilege-1'] - }, - spaces: ['marketing'] + kibana: [ + { + feature: { + foo: ['foo-privilege-1'], + }, + spaces: ['marketing'], + }, + { + feature: { + bar: ['bar-privilege-1'], + }, + spaces: ['sales', 'marketing'], + }, + ], }, - { - feature: { - bar: ['bar-privilege-1'] - }, - spaces: ['sales', 'marketing'] - } - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" position 1 contains a duplicate value]`, - statusCode: 400, - validation: { - keys: ['kibana.1'], - source: 'payload' - } - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot(`"[request body.kibana]: values are not unique"`); }); - putRoleTest(`doesn't allow Kibana reserved privileges`, { - name: 'foo-role', - payload: { - kibana: [ + test(`doesn't allow Kibana reserved privileges`, () => { + expect(() => + requestBodySchema.validate( { - _reserved: ['customApplication1'], - spaces: ['marketing'] + kibana: [ + { + _reserved: ['customApplication1'], + spaces: ['marketing'], + }, + ], }, - ] - }, - asserts: { - statusCode: 400, - result: { - error: 'Bad Request', - //eslint-disable-next-line max-len - message: `child \"kibana\" fails because [\"kibana\" at position 0 fails because [\"_reserved\" is not allowed]]`, - statusCode: 400, - validation: { - keys: ['kibana.0._reserved'], - source: 'payload' - } - }, - }, + {}, + 'request body' + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[request body.kibana.0._reserved]: definition for this key is missing"` + ); }); }); + }); - putRoleTest(`returns result of routePreCheckLicense`, { + describe('failure', () => { + putRoleTest(`returns result of license checker`, { name: 'foo-role', - payload: {}, - preCheckLicenseImpl: () => Boom.forbidden('test forbidden message'), - asserts: { - statusCode: 403, - result: { - error: 'Forbidden', - statusCode: 403, - message: 'test forbidden message', - }, - }, + licenseCheckResult: { check: LICENSE_STATUS.Invalid, message: 'test forbidden message' }, + asserts: { statusCode: 403, result: { body: { message: 'test forbidden message' } } }, }); }); @@ -463,10 +399,9 @@ describe('PUT role', () => { putRoleTest(`creates empty role`, { name: 'foo-role', payload: {}, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], + apiResponses: [async () => ({}), async () => {}], asserts: { - callWithRequests: [ + apiArguments: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], [ 'shield.putRole', @@ -482,23 +417,22 @@ describe('PUT role', () => { ], ], statusCode: 204, - result: null, + result: undefined, }, }); - putRoleTest(`if spaces isn't specifed, defaults to global`, { + putRoleTest(`if spaces isn't specified, defaults to global`, { name: 'foo-role', payload: { kibana: [ { base: ['all'], - } - ] + }, + ], }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], + apiResponses: [async () => ({}), async () => {}], asserts: { - callWithRequests: [ + apiArguments: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], [ 'shield.putRole', @@ -511,9 +445,7 @@ describe('PUT role', () => { applications: [ { application, - privileges: [ - 'all', - ], + privileges: ['all'], resources: [GLOBAL_RESOURCE], }, ], @@ -522,7 +454,7 @@ describe('PUT role', () => { ], ], statusCode: 204, - result: null, + result: undefined, }, }); @@ -533,15 +465,14 @@ describe('PUT role', () => { { base: [], feature: { - foo: ['foo'] - } - } - ] + foo: ['foo'], + }, + }, + ], }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], + apiResponses: [async () => ({}), async () => {}], asserts: { - callWithRequests: [ + apiArguments: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], [ 'shield.putRole', @@ -554,9 +485,7 @@ describe('PUT role', () => { applications: [ { application, - privileges: [ - 'feature_foo.foo', - ], + privileges: ['feature_foo.foo'], resources: [GLOBAL_RESOURCE], }, ], @@ -565,7 +494,7 @@ describe('PUT role', () => { ], ], statusCode: 204, - result: null, + result: undefined, }, }); @@ -575,14 +504,13 @@ describe('PUT role', () => { kibana: [ { base: ['all'], - feature: {} - } - ] + feature: {}, + }, + ], }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], + apiResponses: [async () => ({}), async () => {}], asserts: { - callWithRequests: [ + apiArguments: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], [ 'shield.putRole', @@ -595,9 +523,7 @@ describe('PUT role', () => { applications: [ { application, - privileges: [ - 'all', - ], + privileges: ['all'], resources: [GLOBAL_RESOURCE], }, ], @@ -606,7 +532,7 @@ describe('PUT role', () => { ], ], statusCode: 204, - result: null, + result: undefined, }, }); @@ -622,7 +548,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: ['test-field-security-except-1', 'test-field-security-except-2'] + except: ['test-field-security-except-1', 'test-field-security-except-2'], }, names: ['test-index-name-1', 'test-index-name-2'], privileges: ['test-index-privilege-1', 'test-index-privilege-2'], @@ -638,20 +564,19 @@ describe('PUT role', () => { }, { base: ['all', 'read'], - spaces: ['test-space-1', 'test-space-2'] + spaces: ['test-space-1', 'test-space-2'], }, { feature: { foo: ['foo-privilege-1', 'foo-privilege-2'], }, - spaces: ['test-space-3'] - } - ] + spaces: ['test-space-3'], + }, + ], }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => { }], + apiResponses: [async () => ({}), async () => {}], asserts: { - callWithRequests: [ + apiArguments: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], [ 'shield.putRole', @@ -661,27 +586,18 @@ describe('PUT role', () => { applications: [ { application, - privileges: [ - 'all', - 'read', - ], + privileges: ['all', 'read'], resources: [GLOBAL_RESOURCE], }, { application, - privileges: [ - 'space_all', - 'space_read', - ], - resources: ['space:test-space-1', 'space:test-space-2'] + privileges: ['space_all', 'space_read'], + resources: ['space:test-space-1', 'space:test-space-2'], }, { application, - privileges: [ - 'feature_foo.foo-privilege-1', - 'feature_foo.foo-privilege-2', - ], - resources: ['space:test-space-3'] + privileges: ['feature_foo.foo-privilege-1', 'feature_foo.foo-privilege-2'], + resources: ['space:test-space-3'], }, ], cluster: ['test-cluster-privilege'], @@ -689,13 +605,10 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: ['test-field-security-except-1', 'test-field-security-except-2'] + except: ['test-field-security-except-1', 'test-field-security-except-2'], }, names: ['test-index-name-1', 'test-index-name-2'], - privileges: [ - 'test-index-privilege-1', - 'test-index-privilege-2', - ], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], query: `{ "match": { "title": "foo" } }`, }, ], @@ -706,7 +619,7 @@ describe('PUT role', () => { ], ], statusCode: 204, - result: null, + result: undefined, }, }); @@ -722,7 +635,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: ['test-field-security-except-1', 'test-field-security-except-2'] + except: ['test-field-security-except-1', 'test-field-security-except-2'], }, names: ['test-index-name-1', 'test-index-name-2'], privileges: ['test-index-privilege-1', 'test-index-privilege-2'], @@ -735,24 +648,23 @@ describe('PUT role', () => { { feature: { foo: ['foo-privilege-1'], - bar: ['bar-privilege-1'] + bar: ['bar-privilege-1'], }, - spaces: ['*'] + spaces: ['*'], }, { base: ['all'], - spaces: ['test-space-1', 'test-space-2'] + spaces: ['test-space-1', 'test-space-2'], }, { feature: { - bar: ['bar-privilege-2'] + bar: ['bar-privilege-2'], }, - spaces: ['test-space-3'] - } + spaces: ['test-space-3'], + }, ], }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [ + apiResponses: [ async () => ({ 'foo-role': { metadata: { @@ -766,7 +678,7 @@ describe('PUT role', () => { { field_security: { grant: ['old-field-security-grant-1', 'old-field-security-grant-2'], - except: ['old-field-security-except-1', 'old-field-security-except-2'] + except: ['old-field-security-except-1', 'old-field-security-except-2'], }, names: ['old-index-name'], privileges: ['old-privilege'], @@ -783,10 +695,10 @@ describe('PUT role', () => { ], }, }), - async () => { }, + async () => {}, ], asserts: { - callWithRequests: [ + apiArguments: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], [ 'shield.putRole', @@ -796,25 +708,18 @@ describe('PUT role', () => { applications: [ { application, - privileges: [ - 'feature_foo.foo-privilege-1', - 'feature_bar.bar-privilege-1', - ], + privileges: ['feature_foo.foo-privilege-1', 'feature_bar.bar-privilege-1'], resources: [GLOBAL_RESOURCE], }, { application, - privileges: [ - 'space_all', - ], - resources: ['space:test-space-1', 'space:test-space-2'] + privileges: ['space_all'], + resources: ['space:test-space-1', 'space:test-space-2'], }, { application, - privileges: [ - 'feature_bar.bar-privilege-2', - ], - resources: ['space:test-space-3'] + privileges: ['feature_bar.bar-privilege-2'], + resources: ['space:test-space-3'], }, ], cluster: ['test-cluster-privilege'], @@ -822,13 +727,10 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: ['test-field-security-except-1', 'test-field-security-except-2'] + except: ['test-field-security-except-1', 'test-field-security-except-2'], }, names: ['test-index-name-1', 'test-index-name-2'], - privileges: [ - 'test-index-privilege-1', - 'test-index-privilege-2', - ], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], query: `{ "match": { "title": "foo" } }`, }, ], @@ -839,125 +741,112 @@ describe('PUT role', () => { ], ], statusCode: 204, - result: null, + result: undefined, }, }); - putRoleTest( - `updates role which has existing other application privileges`, - { - name: 'foo-role', - payload: { - metadata: { - foo: 'test-metadata', + putRoleTest(`updates role which has existing other application privileges`, { + name: 'foo-role', + payload: { + metadata: { + foo: 'test-metadata', + }, + elasticsearch: { + cluster: ['test-cluster-privilege'], + indices: [ + { + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + }, + ], + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + kibana: [ + { + base: ['all', 'read'], + spaces: ['*'], }, - elasticsearch: { - cluster: ['test-cluster-privilege'], + ], + }, + apiResponses: [ + async () => ({ + 'foo-role': { + metadata: { + bar: 'old-metadata', + }, + transient_metadata: { + enabled: true, + }, + cluster: ['old-cluster-privilege'], indices: [ { - names: ['test-index-name-1', 'test-index-name-2'], - privileges: [ - 'test-index-privilege-1', - 'test-index-privilege-2', - ], + names: ['old-index-name'], + privileges: ['old-privilege'], }, ], - run_as: ['test-run-as-1', 'test-run-as-2'], - }, - kibana: [ - { - base: ['all', 'read'], - spaces: ['*'] - } - ] - }, - preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [ - async () => ({ - 'foo-role': { - metadata: { - bar: 'old-metadata', + run_as: ['old-run-as'], + applications: [ + { + application, + privileges: ['old-kibana-privilege'], + resources: ['old-resource'], }, - transient_metadata: { - enabled: true, + { + application: 'logstash-foo', + privileges: ['logstash-privilege'], + resources: ['logstash-resource'], }, - cluster: ['old-cluster-privilege'], - indices: [ - { - names: ['old-index-name'], - privileges: ['old-privilege'], - }, - ], - run_as: ['old-run-as'], - applications: [ - { - application, - privileges: ['old-kibana-privilege'], - resources: ['old-resource'], - }, - { - application: 'logstash-foo', - privileges: ['logstash-privilege'], - resources: ['logstash-resource'], - }, - { - application: 'beats-foo', - privileges: ['beats-privilege'], - resources: ['beats-resource'], - }, - ], - }, - }), - async () => { }, - ], - asserts: { - callWithRequests: [ - ['shield.getRole', { name: 'foo-role', ignore: [404] }], - [ - 'shield.putRole', { - name: 'foo-role', - body: { - applications: [ - { - application, - privileges: [ - 'all', - 'read', - ], - resources: [GLOBAL_RESOURCE], - }, - { - application: 'logstash-foo', - privileges: ['logstash-privilege'], - resources: ['logstash-resource'], - }, - { - application: 'beats-foo', - privileges: ['beats-privilege'], - resources: ['beats-resource'], - }, - ], - cluster: ['test-cluster-privilege'], - indices: [ - { - names: ['test-index-name-1', 'test-index-name-2'], - privileges: [ - 'test-index-privilege-1', - 'test-index-privilege-2', - ], - }, - ], - metadata: { foo: 'test-metadata' }, - run_as: ['test-run-as-1', 'test-run-as-2'], - }, + application: 'beats-foo', + privileges: ['beats-privilege'], + resources: ['beats-resource'], }, ], + }, + }), + async () => {}, + ], + asserts: { + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + applications: [ + { + application, + privileges: ['all', 'read'], + resources: [GLOBAL_RESOURCE], + }, + { + application: 'logstash-foo', + privileges: ['logstash-privilege'], + resources: ['logstash-resource'], + }, + { + application: 'beats-foo', + privileges: ['beats-privilege'], + resources: ['beats-resource'], + }, + ], + cluster: ['test-cluster-privilege'], + indices: [ + { + names: ['test-index-name-1', 'test-index-name-2'], + privileges: ['test-index-privilege-1', 'test-index-privilege-2'], + }, + ], + metadata: { foo: 'test-metadata' }, + run_as: ['test-run-as-1', 'test-run-as-2'], + }, + }, ], - statusCode: 204, - result: null, - }, - } - ); + ], + statusCode: 204, + result: undefined, + }, + }); }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts index 681d2220930ef11..196f090cd9db209 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -4,161 +4,249 @@ * you may not use this file except in compliance with the Elastic License. */ -import { flatten, pick, identity, intersection } from 'lodash'; -import Joi from 'joi'; -import { GLOBAL_RESOURCE } from '../../../../../common/constants'; -import { wrapError } from '../../../../../../../../plugins/security/server'; -import { PrivilegeSerializer, ResourceSerializer } from '../../../../lib/authorization'; +import _ from 'lodash'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '../../index'; +import { createLicensedRouteHandler } from '../../licensed_route_handler'; +import { GLOBAL_RESOURCE } from '../../../../common/constants'; +import { wrapError } from '../../../errors'; +import { PrivilegeSerializer } from '../../../authorization/privilege_serializer'; +import { ResourceSerializer } from '../../../authorization/resource_serializer'; +import { ElasticsearchRole } from '.'; -export function initPutRolesApi( - server, - callWithRequest, - routePreCheckLicenseFn, - authorization, - application -) { - - const transformKibanaPrivilegesToEs = (kibanaPrivileges = []) => { - return kibanaPrivileges.map(({ base, feature, spaces }) => { +export function definePutRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) { + const transformPrivilegesToElasticsearchPrivileges = ( + privileges: TypeOf['kibana'] = [] + ) => { + return privileges.map(({ base, feature, spaces }) => { if (spaces.length === 1 && spaces[0] === GLOBAL_RESOURCE) { return { privileges: [ - ...base ? base.map( - privilege => PrivilegeSerializer.serializeGlobalBasePrivilege(privilege) - ) : [], - ...feature ? flatten( - Object.entries(feature).map( - ([featureName, featurePrivileges])=> featurePrivileges.map( - privilege => PrivilegeSerializer.serializeFeaturePrivilege(featureName, privilege) - ) - ) - ) : [] + ...(base + ? base.map(privilege => PrivilegeSerializer.serializeGlobalBasePrivilege(privilege)) + : []), + ...(feature + ? Object.entries(feature) + .map(([featureName, featurePrivileges]) => + featurePrivileges.map(privilege => + PrivilegeSerializer.serializeFeaturePrivilege(featureName, privilege) + ) + ) + .flat() + : []), ], - application, - resources: [GLOBAL_RESOURCE] + application: authz.getApplicationName(), + resources: [GLOBAL_RESOURCE], }; } return { privileges: [ - ...base ? base.map( - privilege => PrivilegeSerializer.serializeSpaceBasePrivilege(privilege) - ) : [], - ...feature ? flatten( - Object.entries(feature).map( - ([featureName, featurePrivileges])=> featurePrivileges.map( - privilege => PrivilegeSerializer.serializeFeaturePrivilege(featureName, privilege) - ) - ) - ) : [] + ...(base + ? base.map(privilege => PrivilegeSerializer.serializeSpaceBasePrivilege(privilege)) + : []), + ...(feature + ? Object.entries(feature) + .map(([featureName, featurePrivileges]) => + featurePrivileges.map(privilege => + PrivilegeSerializer.serializeFeaturePrivilege(featureName, privilege) + ) + ) + .flat() + : []), ], - application, - resources: spaces.map(resource => ResourceSerializer.serializeSpaceResource(resource)), + application: authz.getApplicationName(), + resources: (spaces as string[]).map(resource => + ResourceSerializer.serializeSpaceResource(resource) + ), }; }); }; - const transformRolesToEs = ( - payload, - existingApplications = [] + const transformRolesToElasticsearchRoles = ( + rolePayload: TypeOf, + existingApplications: ElasticsearchRole['applications'] = [] ) => { - const { elasticsearch = {}, kibana = [] } = payload; + const { + elasticsearch = { cluster: undefined, indices: undefined, run_as: undefined }, + kibana = [], + } = rolePayload; const otherApplications = existingApplications.filter( - roleApplication => roleApplication.application !== application + roleApplication => roleApplication.application !== authz.getApplicationName() ); - return pick({ - metadata: payload.metadata, + return { + metadata: rolePayload.metadata, cluster: elasticsearch.cluster || [], indices: elasticsearch.indices || [], run_as: elasticsearch.run_as || [], - applications: [ - ...transformKibanaPrivilegesToEs(kibana), - ...otherApplications, - ], - }, identity); + applications: [...transformPrivilegesToElasticsearchPrivileges(kibana), ...otherApplications], + } as Omit; }; - const getKibanaSchema = () => { - const privileges = authorization.privileges.get(); - const allSpacesSchema = Joi.array().length(1).items(Joi.string().valid([GLOBAL_RESOURCE])); - return Joi.array().items( - Joi.object({ - base: Joi.alternatives().when('spaces', { - is: allSpacesSchema, - then: Joi.array().items(Joi.string().valid(Object.keys(privileges.global))).empty(Joi.array().length(0)), - otherwise: Joi.array().items(Joi.string().valid(Object.keys(privileges.space))).empty(Joi.array().length(0)), - }), - feature: Joi.object() - .pattern(/^[a-zA-Z0-9_-]+$/, Joi.array().items(Joi.string().regex(/^[a-zA-Z0-9_-]+$/))) - .empty(Joi.object().length(0)), - spaces: Joi.alternatives( - allSpacesSchema, - Joi.array().items(Joi.string().regex(/^[a-z0-9_-]+$/)), - ).default([GLOBAL_RESOURCE]), - }) - // the following can be replaced with .oxor once we upgrade Joi - .without('base', ['feature']) - ).unique((a, b) => { - return intersection(a.spaces, b.spaces).length !== 0; - }); - }; + const FEATURE_NAME_VALUE_REGEX = /^[a-zA-Z0-9_-]+$/; + const allSpacesSchema = schema.arrayOf(schema.literal(GLOBAL_RESOURCE), { + minSize: 1, + maxSize: 1, + }); + const spacesSchema = schema.oneOf( + [ + allSpacesSchema, + schema.arrayOf( + schema.string({ + validate(value) { + if (!/^[a-z0-9_-]+$/.test(value)) { + return `must be lower case, a-z, 0-9, '_', and '-' are allowed`; + } + }, + }) + ), + ], + { defaultValue: [GLOBAL_RESOURCE] } + ); - const schema = Joi.object().keys({ - metadata: Joi.object().optional(), - elasticsearch: Joi.object().keys({ - cluster: Joi.array().items(Joi.string()), - indices: Joi.array().items({ - names: Joi.array().items(Joi.string()), - field_security: Joi.object().keys({ - grant: Joi.array().items(Joi.string()), - except: Joi.array().items(Joi.string()), - }), - privileges: Joi.array().items(Joi.string()), - query: Joi.string().allow(''), - allow_restricted_indices: Joi.boolean(), - }), - run_as: Joi.array().items(Joi.string()), + const payloadSchema = schema.object({ + metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())), + elasticsearch: schema.object({ + cluster: schema.maybe(schema.arrayOf(schema.string())), + indices: schema.maybe( + schema.arrayOf( + schema.object({ + names: schema.arrayOf(schema.string(), { defaultValue: [] }), + field_security: schema.maybe( + schema.recordOf( + schema.oneOf([schema.literal('grant'), schema.literal('except')]), + schema.arrayOf(schema.string()) + ) + ), + privileges: schema.arrayOf(schema.string(), { defaultValue: [] }), + query: schema.maybe(schema.string()), + allow_restricted_indices: schema.maybe(schema.boolean()), + }) + ) + ), + run_as: schema.maybe(schema.arrayOf(schema.string())), }), - kibana: Joi.lazy(() => getKibanaSchema()) + kibana: schema.maybe( + schema.arrayOf( + schema.object( + { + spaces: spacesSchema, + base: schema.maybe( + schema.conditional( + schema.siblingRef('spaces'), + allSpacesSchema, + schema.arrayOf( + schema.string({ + validate(value) { + const privilegeNames = Object.keys(authz.privileges.get().global); + if (!privilegeNames.some(globalPrivilege => globalPrivilege === value)) { + return `unknown global privilege "${value}", must be one of [${privilegeNames}]`; + } + }, + }) + ), + schema.arrayOf( + schema.string({ + validate(value) { + const privilegeNames = Object.keys(authz.privileges.get().space); + if (!privilegeNames.some(globalPrivilege => globalPrivilege === value)) { + return `unknown space privilege "${value}", must be one of [${privilegeNames}]`; + } + }, + }) + ) + ) + ), + feature: schema.maybe( + schema.recordOf( + schema.string({ + validate(value) { + if (!FEATURE_NAME_VALUE_REGEX.test(value)) { + return `only a-z, A-Z, 0-9, '_', and '-' are allowed`; + } + }, + }), + schema.arrayOf( + schema.string({ + validate(value) { + if (!FEATURE_NAME_VALUE_REGEX.test(value)) { + return `only a-z, A-Z, 0-9, '_', and '-' are allowed`; + } + }, + }) + ) + ) + ), + }, + { + validate(value) { + if (value.base === undefined && value.feature === undefined) { + return 'either [base] or [feature] is expected, but none of them specified'; + } + + if ( + value.base !== undefined && + value.base.length > 0 && + value.feature !== undefined && + Object.keys(value.feature).length > 0 + ) { + return `definition of [feature] isn't allowed when non-empty [base] is defined.`; + } + }, + } + ), + { + validate(value) { + for (const [indexA, valueA] of value.entries()) { + for (const valueB of value.slice(indexA + 1)) { + if (_.intersection(valueA.spaces, valueB.spaces).length !== 0) { + return 'values are not unique'; + } + } + } + }, + } + ) + ), }); - server.route({ - method: 'PUT', - path: '/api/security/role/{name}', - async handler(request, h) { + router.put( + { + path: '/api/security/role/{name}', + validate: { + params: schema.object({ name: schema.string({ minLength: 1, maxLength: 1024 }) }), + body: payloadSchema, + }, + }, + createLicensedRouteHandler(async (context, request, response) => { const { name } = request.params; try { - const existingRoleResponse = await callWithRequest(request, 'shield.getRole', { - name, - ignore: [404], - }); + const rawRoles: Record = await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.getRole', { + name: request.params.name, + ignore: [404], + }); - const body = transformRolesToEs( - request.payload, - existingRoleResponse[name] ? existingRoleResponse[name].applications : [] + const body = transformRolesToElasticsearchRoles( + request.body, + rawRoles[name] ? rawRoles[name].applications : [] ); - await callWithRequest(request, 'shield.putRole', { name, body }); - return h.response().code(204); - } catch (err) { - throw wrapError(err); + await clusterClient + .asScoped(request) + .callAsCurrentUser('shield.putRole', { name: request.params.name, body }); + + return response.noContent(); + } catch (error) { + const wrappedError = wrapError(error); + return response.customError({ + body: wrappedError, + statusCode: wrappedError.output.statusCode, + }); } - }, - options: { - validate: { - params: Joi.object() - .keys({ - name: Joi.string() - .required() - .min(1) - .max(1024), - }) - .required(), - payload: schema, - }, - pre: [routePreCheckLicenseFn], - }, - }); + }) + ); } diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts new file mode 100644 index 000000000000000..2d3a3154e649902 --- /dev/null +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + elasticsearchServiceMock, + httpServiceMock, + loggingServiceMock, +} from '../../../../../src/core/server/mocks'; +import { authenticationMock } from '../authentication/index.mock'; +import { authorizationMock } from '../authorization/index.mock'; +import { ConfigSchema } from '../config'; + +export const routeDefinitionParamsMock = { + create: () => ({ + router: httpServiceMock.createRouter(), + basePath: httpServiceMock.createBasePath(), + logger: loggingServiceMock.create().get(), + clusterClient: elasticsearchServiceMock.createClusterClient(), + config: { ...ConfigSchema.validate({}), encryptionKey: 'some-enc-key' }, + authc: authenticationMock.create(), + authz: authorizationMock.create(), + getLegacyAPI: jest.fn(), + }), +}; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 289f87d70b1dead..73e276832f4741d 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -4,12 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, IRouter, Logger } from '../../../../../src/core/server'; +import { CoreSetup, IClusterClient, IRouter, Logger } from '../../../../../src/core/server'; import { Authentication } from '../authentication'; +import { Authorization } from '../authorization'; import { ConfigType } from '../config'; -import { defineAuthenticationRoutes } from './authentication'; import { LegacyAPI } from '../plugin'; +import { defineAuthenticationRoutes } from './authentication'; +import { defineAuthorizationRoutes } from './authorization'; + /** * Describes parameters used to define HTTP routes. */ @@ -17,11 +20,14 @@ export interface RouteDefinitionParams { router: IRouter; basePath: CoreSetup['http']['basePath']; logger: Logger; + clusterClient: IClusterClient; config: ConfigType; authc: Authentication; - getLegacyAPI: () => LegacyAPI; + authz: Authorization; + getLegacyAPI: () => Pick; } export function defineRoutes(params: RouteDefinitionParams) { defineAuthenticationRoutes(params); + defineAuthorizationRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/licensed_route_handler.ts b/x-pack/plugins/security/server/routes/licensed_route_handler.ts new file mode 100644 index 000000000000000..de5b842c7d292c6 --- /dev/null +++ b/x-pack/plugins/security/server/routes/licensed_route_handler.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandler } from 'src/core/server'; +import { ObjectType } from '@kbn/config-schema'; +import { LICENSE_STATUS } from '../../../licensing/server/constants'; + +export const createLicensedRouteHandler = < + P extends ObjectType, + Q extends ObjectType, + B extends ObjectType +>( + handler: RequestHandler +) => { + const licensedRouteHandler: RequestHandler = (context, request, responseToolkit) => { + const { license } = context.licensing; + const licenseCheck = license.check('security', 'basic'); + if ( + licenseCheck.check === LICENSE_STATUS.Unavailable || + licenseCheck.check === LICENSE_STATUS.Invalid + ) { + return responseToolkit.forbidden({ body: { message: licenseCheck.message! } }); + } + + return handler(context, request, responseToolkit); + }; + + return licensedRouteHandler; +}; diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts new file mode 100644 index 000000000000000..c31bd038f3af1a9 --- /dev/null +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IClusterClient, KibanaRequest, LegacyRequest } from '../../../../../src/core/server'; +import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; +import { LegacyAPI } from '../plugin'; +import { Authorization } from '../authorization'; +import { SecurityAuditLogger } from '../audit'; + +interface SetupSavedObjectsParams { + adminClusterClient: IClusterClient; + auditLogger: SecurityAuditLogger; + authz: Pick; + legacyAPI: Pick; +} + +export function setupSavedObjects({ + adminClusterClient, + auditLogger, + authz, + legacyAPI: { savedObjects }, +}: SetupSavedObjectsParams) { + const getKibanaRequest = (request: KibanaRequest | LegacyRequest) => + request instanceof KibanaRequest ? request : KibanaRequest.from(request); + savedObjects.setScopedSavedObjectsClientFactory(({ request }) => { + const kibanaRequest = getKibanaRequest(request); + if (authz.mode.useRbacForRequest(kibanaRequest)) { + const internalRepository = savedObjects.getSavedObjectsRepository( + adminClusterClient.callAsInternalUser + ); + return new savedObjects.SavedObjectsClient(internalRepository); + } + + const scopedAdminClusterClient = adminClusterClient.asScoped(kibanaRequest); + const callAsCurrentUserRepository = savedObjects.getSavedObjectsRepository( + scopedAdminClusterClient.callAsCurrentUser + ); + return new savedObjects.SavedObjectsClient(callAsCurrentUserRepository); + }); + + savedObjects.addScopedSavedObjectsClientWrapperFactory( + Number.MAX_SAFE_INTEGER - 1, + 'security', + ({ client, request }) => { + const kibanaRequest = getKibanaRequest(request); + if (authz.mode.useRbacForRequest(kibanaRequest)) { + return new SecureSavedObjectsClientWrapper({ + actions: authz.actions, + auditLogger, + baseClient: client, + checkSavedObjectsPrivilegesAsCurrentUser: authz.checkSavedObjectsPrivilegesWithRequest( + kibanaRequest + ), + errors: savedObjects.SavedObjectsClient.errors, + }); + } + + return client; + } + ); +} diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 8bc1aa0fbe2f8f4..f802c011f207e2e 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -5,46 +5,42 @@ */ import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; +import { Actions } from '../authorization'; +import { securityAuditLoggerMock } from '../audit/index.mock'; +import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { SavedObjectsClientContract } from 'kibana/server'; + +const createSecureSavedObjectsClientWrapperOptions = () => { + const actions = new Actions('some-version'); + jest + .spyOn(actions.savedObject, 'get') + .mockImplementation((type: string, action: string) => `mock-saved_object:${type}/${action}`); -const createMockErrors = () => { const forbiddenError = new Error('Mock ForbiddenError'); const generalError = new Error('Mock GeneralError'); - return { - forbiddenError, + const errors = ({ decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), - generalError, - decorateGeneralError: jest.fn().mockReturnValue(generalError) - }; -}; + decorateGeneralError: jest.fn().mockReturnValue(generalError), + } as unknown) as jest.Mocked; -const createMockAuditLogger = () => { return { - savedObjectsAuthorizationFailure: jest.fn(), - savedObjectsAuthorizationSuccess: jest.fn(), - }; -}; - -const createMockActions = () => { - return { - savedObject: { - get(type, action) { - return `mock-saved_object:${type}/${action}`; - } - } + actions, + baseClient: savedObjectsClientMock.create(), + checkSavedObjectsPrivilegesAsCurrentUser: jest.fn(), + errors, + auditLogger: securityAuditLoggerMock.create(), + forbiddenError, + generalError, }; }; describe('#errors', () => { test(`assigns errors from constructor to .errors`, () => { - const errors = Symbol(); - - const client = new SecureSavedObjectsClientWrapper({ - checkSavedObjectsPrivilegesWithRequest: () => {}, - errors - }); + const options = createSecureSavedObjectsClientWrapperOptions(); + const client = new SecureSavedObjectsClientWrapper(options); - expect(client.errors).toBe(errors); + expect(client.errors).toBe(options.errors); }); }); @@ -52,1088 +48,775 @@ describe(`spaces disabled`, () => { describe('#create', () => { test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckSavedObjectsPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckSavedObjectsPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); - await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckSavedObjectsPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'create')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + await expect(client.create(type)).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'create')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { const type = 'foo'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, - privileges: { - [mockActions.savedObject.get(type, 'create')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + privileges: { [options.actions.savedObject.get(type, 'create')]: false }, }); - const attributes = Symbol(); - const options = Object.freeze({ namespace: Symbol() }); - await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'create')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + const attributes = { some_attr: 's' }; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.create(type, attributes, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'create')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'create', [type], - [mockActions.savedObject.get(type, 'create')], - { - type, - attributes, - options, - } + [options.actions.savedObject.get(type, 'create')], + { type, attributes, options: apiCallOptions } ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of baseClient.create when authorized`, async () => { const type = 'foo'; const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - create: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: true, username, - privileges: { - [mockActions.savedObject.get(type, 'create')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const attributes = Symbol(); - const options = Object.freeze({ namespace: Symbol() }); - - const result = await client.create(type, attributes, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'create')], options.namespace); - expect(mockBaseClient.create).toHaveBeenCalledWith(type, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { - type, - attributes, - options, + privileges: { [options.actions.savedObject.get(type, 'create')]: true }, }); + + const apiCallReturnValue = Symbol(); + options.baseClient.create.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const attributes = { some_attr: 's' }; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.create(type, attributes, apiCallOptions)).resolves.toBe( + apiCallReturnValue + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'create')], + apiCallOptions.namespace + ); + expect(options.baseClient.create).toHaveBeenCalledWith(type, attributes, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'create', + [type], + { type, attributes, options: apiCallOptions } + ); }); }); describe('#bulkCreate', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - const options = Object.freeze({ namespace: Symbol() }); - - await expect(client.bulkCreate([{ type }], options)).rejects.toThrowError(mockErrors.generalError); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_create')], options.namespace); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect( + client.bulkCreate([{ type, attributes: {} }], apiCallOptions) + ).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_create')], + apiCallOptions.namespace + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { const type1 = 'foo'; const type2 = 'bar'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, privileges: { - [mockActions.savedObject.get(type1, 'bulk_create')]: false, - [mockActions.savedObject.get(type2, 'bulk_create')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + [options.actions.savedObject.get(type1, 'bulk_create')]: false, + [options.actions.savedObject.get(type2, 'bulk_create')]: true, + }, }); - const objects = [ - { type: type1 }, - { type: type1 }, - { type: type2 }, - ]; - const options = Object.freeze({ namespace: Symbol() }); - await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type: type1, attributes: {} }, { type: type2, attributes: {} }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkCreate(objects, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_create'), - mockActions.savedObject.get(type2, 'bulk_create'), - ], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'bulk_create'), + options.actions.savedObject.get(type2, 'bulk_create'), + ], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'bulk_create', [type1, type2], - [mockActions.savedObject.get(type1, 'bulk_create')], - { - objects, - options, - } + [options.actions.savedObject.get(type1, 'bulk_create')], + { objects, options: apiCallOptions } ); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of baseClient.bulkCreate when authorized`, async () => { - const username = Symbol(); const type1 = 'foo'; const type2 = 'bar'; - const returnValue = Symbol(); - const mockBaseClient = { - bulkCreate: jest.fn().mockReturnValue(returnValue) - }; - const mockActions = createMockActions(); - const mockCheckPrivileges = jest.fn(async () => ({ + const username = Symbol(); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: true, username, privileges: { - [mockActions.savedObject.get(type1, 'bulk_create')]: true, - [mockActions.savedObject.get(type2, 'bulk_create')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + [options.actions.savedObject.get(type1, 'bulk_create')]: true, + [options.actions.savedObject.get(type2, 'bulk_create')]: true, + }, }); + + const apiCallReturnValue = Symbol(); + options.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + const objects = [ - { type: type1, otherThing: 'sup' }, - { type: type2, otherThing: 'everyone' }, + { type: type1, otherThing: 'sup', attributes: {} }, + { type: type2, otherThing: 'everyone', attributes: {} }, ]; - const options = Object.freeze({ namespace: Symbol() }); - - const result = await client.bulkCreate(objects, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_create'), - mockActions.savedObject.get(type2, 'bulk_create'), - ], options.namespace); - expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { - objects, - options, - }); + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkCreate(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'bulk_create'), + options.actions.savedObject.get(type2, 'bulk_create'), + ], + apiCallOptions.namespace + ); + expect(options.baseClient.bulkCreate).toHaveBeenCalledWith(objects, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'bulk_create', + [type1, type2], + { objects, options: apiCallOptions } + ); }); }); describe('#delete', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'delete')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + await expect(client.delete(type, 'bar')).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'delete')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { const type = 'foo'; + const id = 'bar'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, privileges: { - [mockActions.savedObject.get(type, 'delete')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + [options.actions.savedObject.get(type, 'delete')]: false, + }, }); - const id = Symbol(); - const options = Object.freeze({ namespace: Symbol() }); + const client = new SecureSavedObjectsClientWrapper(options); - await expect(client.delete(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.delete(type, id, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'delete')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'delete')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'delete', [type], - [mockActions.savedObject.get(type, 'delete')], - { - type, - id, - options, - } + [options.actions.savedObject.get(type, 'delete')], + { type, id, options: apiCallOptions } ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of internalRepository.delete when authorized`, async () => { const type = 'foo'; + const id = 'bar'; const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - delete: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: true, username, - privileges: { - [mockActions.savedObject.get(type, 'delete')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const options = Object.freeze({ namespace: Symbol() }); - - const result = await client.delete(type, id, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'delete')], options.namespace); - expect(mockBaseClient.delete).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { - type, - id, - options, + privileges: { [options.actions.savedObject.get(type, 'delete')]: true }, }); + + const apiCallReturnValue = Symbol(); + options.baseClient.delete.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.delete(type, id, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'delete')], + apiCallOptions.namespace + ); + expect(options.baseClient.delete).toHaveBeenCalledWith(type, id, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'delete', + [type], + { type, id, options: apiCallOptions } + ); }); }); describe('#find', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'find')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + await expect(client.find({ type })).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'find')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { const type = 'foo'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, - privileges: { - [mockActions.savedObject.get(type, 'find')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + privileges: { [options.actions.savedObject.get(type, 'find')]: false }, }); - const options = Object.freeze({ type, namespace: Symbol }); - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ type, namespace: 'some-ns' }); + await expect(client.find(apiCallOptions)).rejects.toThrowError(options.forbiddenError); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'find')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'find')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'find', [type], - [mockActions.savedObject.get(type, 'find')], - { - options - } + [options.actions.savedObject.get(type, 'find')], + { options: apiCallOptions } ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { const type1 = 'foo'; const type2 = 'bar'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, privileges: { - [mockActions.savedObject.get(type1, 'find')]: false, - [mockActions.savedObject.get(type2, 'find')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + [options.actions.savedObject.get(type1, 'find')]: false, + [options.actions.savedObject.get(type2, 'find')]: true, + }, }); - const options = Object.freeze({ type: [type1, type2], namespace: Symbol() }); - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'find'), - mockActions.savedObject.get(type2, 'find') - ], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + const apiCallOptions = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + await expect(client.find(apiCallOptions)).rejects.toThrowError(options.forbiddenError); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'find'), + options.actions.savedObject.get(type2, 'find'), + ], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'find', [type1, type2], - [mockActions.savedObject.get(type1, 'find')], - { - options - } + [options.actions.savedObject.get(type1, 'find')], + { options: apiCallOptions } ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of baseClient.find when authorized`, async () => { const type = 'foo'; const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - find: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: true, username, - privileges: { - [mockActions.savedObject.get(type, 'find')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + privileges: { [options.actions.savedObject.get(type, 'find')]: true }, }); - const options = Object.freeze({ type, namespace: Symbol }); - const result = await client.find(options); + const apiCallReturnValue = Symbol(); + options.baseClient.find.mockReturnValue(apiCallReturnValue as any); - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'find')], options.namespace); - expect(mockBaseClient.find).toHaveBeenCalledWith(options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { - options, - }); + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ type, namespace: 'some-ns' }); + await expect(client.find(apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'find')], + apiCallOptions.namespace + ); + expect(options.baseClient.find).toHaveBeenCalledWith(apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'find', + [type], + { options: apiCallOptions } + ); }); }); describe('#bulkGet', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_get')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + await expect(client.bulkGet([{ id: 'bar', type }])).rejects.toThrowError( + options.generalError + ); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_get')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { const type1 = 'foo'; const type2 = 'bar'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, privileges: { - [mockActions.savedObject.get(type1, 'bulk_get')]: false, - [mockActions.savedObject.get(type2, 'bulk_get')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + [options.actions.savedObject.get(type1, 'bulk_get')]: false, + [options.actions.savedObject.get(type2, 'bulk_get')]: true, + }, }); - const objects = [ - { type: type1 }, - { type: type1 }, - { type: type2 }, - ]; - const options = Object.freeze({ namespace: Symbol }); - await expect(client.bulkGet(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_get'), - mockActions.savedObject.get(type2, 'bulk_get'), - ], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + const objects = [{ type: type1, id: `bar-${type1}` }, { type: type2, id: `bar-${type2}` }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkGet(objects, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'bulk_get'), + options.actions.savedObject.get(type2, 'bulk_get'), + ], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'bulk_get', [type1, type2], - [mockActions.savedObject.get(type1, 'bulk_get')], - { - objects, - options, - } + [options.actions.savedObject.get(type1, 'bulk_get')], + { objects, options: apiCallOptions } ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of baseClient.bulkGet when authorized`, async () => { const type1 = 'foo'; const type2 = 'bar'; const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - bulkGet: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: true, username, privileges: { - [mockActions.savedObject.get(type1, 'bulk_get')]: true, - [mockActions.savedObject.get(type2, 'bulk_get')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const objects = [ - { type: type1, id: 'foo-id' }, - { type: type2, id: 'bar-id' }, - ]; - const options = Object.freeze({ namespace: Symbol }); - - const result = await client.bulkGet(objects, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.savedObject.get(type1, 'bulk_get'), - mockActions.savedObject.get(type2, 'bulk_get'), - ], options.namespace); - expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { - objects, - options, + [options.actions.savedObject.get(type1, 'bulk_get')]: true, + [options.actions.savedObject.get(type2, 'bulk_get')]: true, + }, }); + + const apiCallReturnValue = Symbol(); + options.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type: type1, id: `id-${type1}` }, { type: type2, id: `id-${type2}` }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkGet(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [ + options.actions.savedObject.get(type1, 'bulk_get'), + options.actions.savedObject.get(type2, 'bulk_get'), + ], + apiCallOptions.namespace + ); + expect(options.baseClient.bulkGet).toHaveBeenCalledWith(objects, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'bulk_get', + [type1, type2], + { objects, options: apiCallOptions } + ); }); }); describe('#get', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'get')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + await expect(client.get(type, 'bar')).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'get')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { const type = 'foo'; + const id = 'bar'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, privileges: { - [mockActions.savedObject.get(type, 'get')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + [options.actions.savedObject.get(type, 'get')]: false, + }, }); - const id = Symbol(); - const options = Object.freeze({ namespace: Symbol }); - await expect(client.get(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.get(type, id, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'get')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'get')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'get', [type], - [mockActions.savedObject.get(type, 'get')], - { - type, - id, - options, - } + [options.actions.savedObject.get(type, 'get')], + { type, id, options: apiCallOptions } ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of baseClient.get when authorized`, async () => { const type = 'foo'; + const id = 'bar'; const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - get: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: true, username, - privileges: { - [mockActions.savedObject.get(type, 'get')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const options = Object.freeze({ namespace: Symbol }); - - const result = await client.get(type, id, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'get')], options.namespace); - expect(mockBaseClient.get).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { - type, - id, - options + privileges: { [options.actions.savedObject.get(type, 'get')]: true }, }); + + const apiCallReturnValue = Symbol(); + options.baseClient.get.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.get(type, id, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'get')], + apiCallOptions.namespace + ); + expect(options.baseClient.get).toHaveBeenCalledWith(type, id, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'get', + [type], + { type, id, options: apiCallOptions } + ); }); }); describe('#update', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - - await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'update')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + await expect(client.update(type, 'bar', {})).rejects.toThrowError(options.generalError); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'update')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { const type = 'foo'; + const id = 'bar'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, privileges: { - [mockActions.savedObject.get(type, 'update')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + [options.actions.savedObject.get(type, 'update')]: false, + }, }); - const id = Symbol(); - const attributes = Symbol(); - const options = Object.freeze({ namespace: Symbol }); - await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); + const client = new SecureSavedObjectsClientWrapper(options); + + const attributes = { some: 'attr' }; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.update(type, id, attributes, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'update')], options.namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'update')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'update', [type], - [mockActions.savedObject.get(type, 'update')], - { - type, - id, - attributes, - options, - } + [options.actions.savedObject.get(type, 'update')], + { type, id, attributes, options: apiCallOptions } ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of baseClient.update when authorized`, async () => { const type = 'foo'; + const id = 'bar'; const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - update: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: true, username, - privileges: { - [mockActions.savedObject.get(type, 'update')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const attributes = Symbol(); - const options = Object.freeze({ namespace: Symbol }); - - const result = await client.update(type, id, attributes, options); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'update')], options.namespace); - expect(mockBaseClient.update).toHaveBeenCalledWith(type, id, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { - type, - id, - attributes, - options, + privileges: { [options.actions.savedObject.get(type, 'update')]: true }, }); + + const apiCallReturnValue = Symbol(); + options.baseClient.update.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const attributes = { some: 'attr' }; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.update(type, id, attributes, apiCallOptions)).resolves.toBe( + apiCallReturnValue + ); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'update')], + apiCallOptions.namespace + ); + expect(options.baseClient.update).toHaveBeenCalledWith(type, id, attributes, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'update', + [type], + { type, id, attributes, options: apiCallOptions } + ); }); }); describe('#bulkUpdate', () => { test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => { - throw new Error('An actual error would happen here'); - }); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue( + new Error('An actual error would happen here') + ); + const client = new SecureSavedObjectsClientWrapper(options); - const objects = [{ - type - }]; - await expect( - client.bulkUpdate(objects) - ).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], undefined); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + await expect(client.bulkUpdate([{ id: 'bar', type, attributes: {} }])).rejects.toThrowError( + options.generalError + ); + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_update')], + undefined + ); + expect(options.errors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { const type = 'foo'; const username = Symbol(); - const mockActions = createMockActions(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: false, username, privileges: { - [mockActions.savedObject.get(type, 'bulk_update')]: false, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: null, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: mockErrors, - request: mockRequest, - savedObjectTypes: [], - spaces: null, + [options.actions.savedObject.get(type, 'bulk_update')]: false, + }, }); - const id = Symbol(); - const attributes = Symbol(); - const namespace = Symbol(); - await expect( - client.bulkUpdate([{ type, id, attributes }], { namespace }) - ).rejects.toThrowError(mockErrors.forbiddenError); + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type, id: `bar-${type}`, attributes: {} }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkUpdate(objects, apiCallOptions)).rejects.toThrowError( + options.forbiddenError + ); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], namespace); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_update')], + apiCallOptions.namespace + ); + expect(options.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(options.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( username, 'bulk_update', [type], - [mockActions.savedObject.get(type, 'bulk_update')], - { - objects: [ - { - type, - id, - attributes, - } - ], - options: { namespace } - } + [options.actions.savedObject.get(type, 'bulk_update')], + { objects, options: apiCallOptions } ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); }); test(`returns result of baseClient.bulkUpdate when authorized`, async () => { const type = 'foo'; const username = Symbol(); - const returnValue = Symbol(); - const mockActions = createMockActions(); - const mockBaseClient = { - bulkUpdate: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn(async () => ({ + const options = createSecureSavedObjectsClientWrapperOptions(); + options.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue({ hasAllRequested: true, username, privileges: { - [mockActions.savedObject.get(type, 'bulkUpdate')]: true, - } - })); - const mockCheckSavedObjectsPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); - const mockRequest = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClientWrapper({ - actions: mockActions, - auditLogger: mockAuditLogger, - baseClient: mockBaseClient, - checkSavedObjectsPrivilegesWithRequest: mockCheckSavedObjectsPrivilegesWithRequest, - errors: null, - request: mockRequest, - savedObjectTypes: [], - spaces: null, - }); - const id = Symbol(); - const attributes = Symbol(); - const namespace = Symbol(); - - const result = await client.bulkUpdate([{ type, id, attributes }], { namespace }); - - expect(result).toBe(returnValue); - expect(mockCheckSavedObjectsPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.savedObject.get(type, 'bulk_update')], namespace); - expect(mockBaseClient.bulkUpdate).toHaveBeenCalledWith([{ type, id, attributes }], { namespace }); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_update', [type], { - objects: [{ - type, - id, - attributes, - }], - options: { namespace } + [options.actions.savedObject.get(type, 'bulk_update')]: true, + }, }); + + const apiCallReturnValue = Symbol(); + options.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); + + const client = new SecureSavedObjectsClientWrapper(options); + + const objects = [{ type, id: `id-${type}`, attributes: {} }]; + const apiCallOptions = Object.freeze({ namespace: 'some-ns' }); + await expect(client.bulkUpdate(objects, apiCallOptions)).resolves.toBe(apiCallReturnValue); + + expect(options.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( + [options.actions.savedObject.get(type, 'bulk_update')], + apiCallOptions.namespace + ); + expect(options.baseClient.bulkUpdate).toHaveBeenCalledWith(objects, apiCallOptions); + expect(options.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(options.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( + username, + 'bulk_update', + [type], + { objects, options: apiCallOptions } + ); }); }); }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index d45e42e430a0bd5..03b1d770fa77069 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -4,151 +4,180 @@ * you may not use this file except in compliance with the Elastic License. */ -import { get, uniq } from 'lodash'; - -export class SecureSavedObjectsClientWrapper { - constructor(options) { - const { - actions, - auditLogger, - baseClient, - checkSavedObjectsPrivilegesWithRequest, - errors, - request, - savedObjectTypes, - } = options; +import { + SavedObjectAttributes, + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkGetObject, + SavedObjectsBulkUpdateObject, + SavedObjectsClientContract, + SavedObjectsCreateOptions, + SavedObjectsFindOptions, + SavedObjectsUpdateOptions, +} from '../../../../../src/core/server'; +import { SecurityAuditLogger } from '../audit'; +import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; + +interface SecureSavedObjectsClientWrapperOptions { + actions: Actions; + auditLogger: SecurityAuditLogger; + baseClient: SavedObjectsClientContract; + errors: SavedObjectsClientContract['errors']; + checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; +} +export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContract { + private readonly actions: Actions; + private readonly auditLogger: PublicMethodsOf; + private readonly baseClient: SavedObjectsClientContract; + private readonly checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; + public readonly errors: SavedObjectsClientContract['errors']; + constructor({ + actions, + auditLogger, + baseClient, + checkSavedObjectsPrivilegesAsCurrentUser, + errors, + }: SecureSavedObjectsClientWrapperOptions) { this.errors = errors; - this._actions = actions; - this._auditLogger = auditLogger; - this._baseClient = baseClient; - this._checkSavedObjectsPrivileges = checkSavedObjectsPrivilegesWithRequest(request); - this._savedObjectTypes = savedObjectTypes; + this.actions = actions; + this.auditLogger = auditLogger; + this.baseClient = baseClient; + this.checkSavedObjectsPrivilegesAsCurrentUser = checkSavedObjectsPrivilegesAsCurrentUser; } - async create(type, attributes = {}, options = {}) { - await this._ensureAuthorized( - type, - 'create', - options.namespace, - { type, attributes, options }, - ); + public async create( + type: string, + attributes: T = {} as T, + options: SavedObjectsCreateOptions = {} + ) { + await this.ensureAuthorized(type, 'create', options.namespace, { type, attributes, options }); - return await this._baseClient.create(type, attributes, options); + return await this.baseClient.create(type, attributes, options); } - async bulkCreate(objects, options = {}) { - const types = uniq(objects.map(o => o.type)); - await this._ensureAuthorized( - types, + public async bulkCreate( + objects: SavedObjectsBulkCreateObject[], + options: SavedObjectsBaseOptions = {} + ) { + await this.ensureAuthorized( + this.getUniqueObjectTypes(objects), 'bulk_create', options.namespace, - { objects, options }, + { objects, options } ); - return await this._baseClient.bulkCreate(objects, options); + return await this.baseClient.bulkCreate(objects, options); } - async delete(type, id, options = {}) { - await this._ensureAuthorized( - type, - 'delete', - options.namespace, - { type, id, options }, - ); + public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + await this.ensureAuthorized(type, 'delete', options.namespace, { type, id, options }); - return await this._baseClient.delete(type, id, options); + return await this.baseClient.delete(type, id, options); } - async find(options = {}) { - await this._ensureAuthorized( - options.type, - 'find', - options.namespace, - { options } - ); + public async find(options: SavedObjectsFindOptions) { + await this.ensureAuthorized(options.type, 'find', options.namespace, { options }); - return this._baseClient.find(options); + return this.baseClient.find(options); } - async bulkGet(objects = [], options = {}) { - const types = uniq(objects.map(o => o.type)); - await this._ensureAuthorized( - types, - 'bulk_get', - options.namespace, - { objects, options }, - ); + public async bulkGet( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_get', options.namespace, { + objects, + options, + }); - return await this._baseClient.bulkGet(objects, options); + return await this.baseClient.bulkGet(objects, options); } - async get(type, id, options = {}) { - await this._ensureAuthorized( - type, - 'get', - options.namespace, - { type, id, options }, - ); + public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + await this.ensureAuthorized(type, 'get', options.namespace, { type, id, options }); - return await this._baseClient.get(type, id, options); + return await this.baseClient.get(type, id, options); } - async update(type, id, attributes, options = {}) { - await this._ensureAuthorized( + public async update( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ) { + await this.ensureAuthorized(type, 'update', options.namespace, { type, - 'update', - options.namespace, - { type, id, attributes, options }, - ); + id, + attributes, + options, + }); - return await this._baseClient.update(type, id, attributes, options); + return await this.baseClient.update(type, id, attributes, options); } - async bulkUpdate(objects = [], options) { - const types = uniq(objects.map(o => o.type)); - await this._ensureAuthorized( - types, + public async bulkUpdate( + objects: SavedObjectsBulkUpdateObject[] = [], + options: SavedObjectsBaseOptions = {} + ) { + await this.ensureAuthorized( + this.getUniqueObjectTypes(objects), 'bulk_update', options && options.namespace, - { objects, options }, + { objects, options } ); - return await this._baseClient.bulkUpdate(objects, options); + return await this.baseClient.bulkUpdate(objects, options); } - async _checkPrivileges(actions, namespace) { + private async checkPrivileges(actions: string | string[], namespace?: string) { try { - return await this._checkSavedObjectsPrivileges(actions, namespace); + return await this.checkSavedObjectsPrivilegesAsCurrentUser(actions, namespace); } catch (error) { - const { reason } = get(error, 'body.error', {}); - throw this.errors.decorateGeneralError(error, reason); + throw this.errors.decorateGeneralError(error, error.body && error.body.reason); } } - async _ensureAuthorized(typeOrTypes, action, namespace, args) { + private async ensureAuthorized( + typeOrTypes: string | string[], + action: string, + namespace?: string, + args?: Record + ) { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - const actionsToTypesMap = new Map(types.map(type => [this._actions.savedObject.get(type, action), type])); + const actionsToTypesMap = new Map( + types.map(type => [this.actions.savedObject.get(type, action), type]) + ); const actions = Array.from(actionsToTypesMap.keys()); - const { hasAllRequested, username, privileges } = await this._checkPrivileges(actions, namespace); + const { hasAllRequested, username, privileges } = await this.checkPrivileges( + actions, + namespace + ); if (hasAllRequested) { - this._auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + this.auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); } else { - const missingPrivileges = this._getMissingPrivileges(privileges); - this._auditLogger.savedObjectsAuthorizationFailure( + const missingPrivileges = this.getMissingPrivileges(privileges); + this.auditLogger.savedObjectsAuthorizationFailure( username, action, types, missingPrivileges, args ); - const msg = `Unable to ${action} ${missingPrivileges.map(privilege => actionsToTypesMap.get(privilege)).sort().join(',')}`; + const msg = `Unable to ${action} ${missingPrivileges + .map(privilege => actionsToTypesMap.get(privilege)) + .sort() + .join(',')}`; throw this.errors.decorateForbiddenError(new Error(msg)); } } - _getMissingPrivileges(response) { - return Object.keys(response).filter(privilege => !response[privilege]); + private getMissingPrivileges(privileges: Record) { + return Object.keys(privileges).filter(privilege => !privileges[privilege]); + } + + private getUniqueObjectTypes(objects: Array<{ type: string }>) { + return [...new Set(objects.map(o => o.type))]; } } diff --git a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts index cf22394a086163a..1af538c49a59702 100644 --- a/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts +++ b/x-pack/test/api_integration/apis/security/builtin_es_privileges.ts @@ -11,10 +11,10 @@ export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('Builtin ES Privileges', () => { - describe('GET /api/security/v1/esPrivileges/builtin', () => { + describe('GET /api/security/esPrivileges/builtin', () => { it('should return a list of available builtin privileges', async () => { await supertest - .get('/api/security/v1/esPrivileges/builtin') + .get('/api/security/esPrivileges/builtin') .set('kbn-xsrf', 'xxx') .send() .expect(200)