diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js index 9010af6a71234..3c0e2854428c4 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js @@ -45,7 +45,8 @@ function executeJobFn(server) { return callWithRequest(fakeRequest, endpoint, clientParams, options); }; const savedObjectsClient = server.savedObjectsClientFactory({ - callCluster: callEndpoint + callCluster: callEndpoint, + request: fakeRequest }); const uiSettings = server.uiSettingsServiceFactory({ savedObjectsClient diff --git a/x-pack/plugins/security/common/constants.js b/x-pack/plugins/security/common/constants.js new file mode 100644 index 0000000000000..2786acb56b057 --- /dev/null +++ b/x-pack/plugins/security/common/constants.js @@ -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 const DEFAULT_RESOURCE = 'default'; diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 2810f7ae3cb7f..0346fa90b9cfa 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -19,9 +19,10 @@ import { initAuthenticator } from './server/lib/authentication/authenticator'; import { mirrorStatusAndInitialize } from './server/lib/mirror_status_and_initialize'; import { secureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/saved_objects_client_wrapper'; import { secureSavedObjectsClientOptionsBuilder } from './server/lib/saved_objects_client/secure_options_builder'; -import { registerPrivilegesWithCluster } from './server/lib/privileges/privilege_action_registry'; +import { registerPrivilegesWithCluster } from './server/lib/privileges'; import { createDefaultRoles } from './server/lib/authorization/create_default_roles'; import { initPrivilegesApi } from './server/routes/api/v1/privileges'; +import { hasPrivilegesWithServer } from './server/lib/authorization/has_privileges'; export const security = (kibana) => new kibana.Plugin({ id: 'security', @@ -45,7 +46,10 @@ export const security = (kibana) => new kibana.Plugin({ rbac: Joi.object({ enabled: Joi.boolean().default(false), createDefaultRoles: Joi.boolean().default(true), - application: Joi.string().default('kibana'), + application: Joi.string().default('kibana').regex( + /[a-zA-Z0-9-_]+/, + `may contain alphanumeric characters (a-z, A-Z, 0-9), underscores and hyphens` + ), }).default(), }).default(); }, @@ -75,7 +79,8 @@ export const security = (kibana) => new kibana.Plugin({ return { secureCookies: config.get('xpack.security.secureCookies'), sessionTimeout: config.get('xpack.security.sessionTimeout'), - rbacEnabled: config.get('xpack.security.rbac.enabled') + rbacEnabled: config.get('xpack.security.rbac.enabled'), + rbacApplication: config.get('xpack.security.rbac.application'), }; } }, @@ -107,8 +112,11 @@ export const security = (kibana) => new kibana.Plugin({ server.auth.strategy('session', 'login', 'required'); if (config.get('xpack.security.rbac.enabled')) { + const hasPrivilegesWithRequest = hasPrivilegesWithServer(server); const savedObjectsClientProvider = server.getSavedObjectsClientProvider(); - savedObjectsClientProvider.addClientOptionBuilder((options) => secureSavedObjectsClientOptionsBuilder(server, options)); + savedObjectsClientProvider.addClientOptionBuilder(options => + secureSavedObjectsClientOptionsBuilder(server, hasPrivilegesWithRequest, options) + ); savedObjectsClientProvider.addClientWrapper(secureSavedObjectsClientWrapper); } diff --git a/x-pack/plugins/security/public/views/management/edit_role.html b/x-pack/plugins/security/public/views/management/edit_role.html index 768f50a210758..8c8c5d692cce2 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.html +++ b/x-pack/plugins/security/public/views/management/edit_role.html @@ -1,8 +1,10 @@
+
+

@@ -39,6 +41,18 @@

+
+
+
+ + + This role contains application privileges for the {{ otherApplications.join(', ') }} application(s) that can't be edited. + If they are for other instances of Kibana, you must manage those privileges on that Kibana. + +
+
+
+
@@ -56,7 +70,7 @@

ng-model="role.name" required pattern="[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*" - maxlength="30" + maxlength="1024" data-test-subj="roleFormNameInput" /> @@ -102,20 +116,15 @@

Kibana Privileges -
- Changes to this section are not supported: this role contains application privileges that do not belong to this instance of Kibana. -
- -
+
diff --git a/x-pack/plugins/security/public/views/management/edit_role.js b/x-pack/plugins/security/public/views/management/edit_role.js index 349807d1836c8..3f0ab02129dbb 100644 --- a/x-pack/plugins/security/public/views/management/edit_role.js +++ b/x-pack/plugins/security/public/views/management/edit_role.js @@ -21,6 +21,57 @@ import { IndexPatternsProvider } from 'ui/index_patterns/index_patterns'; import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; import { checkLicenseError } from 'plugins/security/lib/check_license_error'; import { EDIT_ROLES_PATH, ROLES_PATH } from './management_urls'; +import { DEFAULT_RESOURCE } from '../../../common/constants'; + +const getKibanaPrivileges = (kibanaApplicationPrivilege, role, application) => { + const kibanaPrivileges = kibanaApplicationPrivilege.reduce((acc, p) => { + acc[p.name] = false; + return acc; + }, {}); + + if (!role.applications || role.applications.length === 0) { + return kibanaPrivileges; + } + + const applications = role.applications.filter(x => x.application === application); + + const assigned = _.uniq(_.flatten(_.pluck(applications, 'privileges'))); + assigned.forEach(a => { + kibanaPrivileges[a] = true; + }); + + return kibanaPrivileges; +}; + +const setApplicationPrivileges = (kibanaPrivileges, role, application) => { + if (!role.applications) { + role.applications = []; + } + + // we first remove the matching application entries + role.applications = role.applications.filter(x => { + return x.application !== application; + }); + + const privileges = Object.keys(kibanaPrivileges).filter(key => kibanaPrivileges[key]); + + // if we still have them, put the application entry back + if (privileges.length > 0) { + role.applications = [...role.applications, { + application, + privileges, + resources: [ DEFAULT_RESOURCE ] + }]; + } +}; + +const getOtherApplications = (kibanaPrivileges, role, application) => { + if (!role.applications || role.applications.length === 0) { + return []; + } + + return role.applications.map(x => x.application).filter(x => x !== application); +}; routes.when(`${EDIT_ROLES_PATH}/:name?`, { template, @@ -48,7 +99,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { applications: [] }); }, - kibanaPrivileges(ApplicationPrivilege, kbnUrl, Promise, Private) { + kibanaApplicationPrivilege(ApplicationPrivilege, kbnUrl, Promise, Private) { return ApplicationPrivilege.query().$promise .catch(checkLicenseError(kbnUrl, Promise, Private)); }, @@ -64,7 +115,7 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { } }, controllerAs: 'editRole', - controller($injector, $scope, rbacEnabled) { + controller($injector, $scope, rbacEnabled, rbacApplication) { const $route = $injector.get('$route'); const kbnUrl = $injector.get('kbnUrl'); const shieldPrivileges = $injector.get('shieldPrivileges'); @@ -77,8 +128,13 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { $scope.users = $route.current.locals.users; $scope.indexPatterns = $route.current.locals.indexPatterns; $scope.privileges = shieldPrivileges; - $scope.kibanaPrivileges = $route.current.locals.kibanaPrivileges; + $scope.rbacEnabled = rbacEnabled; + const kibanaApplicationPrivilege = $route.current.locals.kibanaApplicationPrivilege; + const role = $route.current.locals.role; + $scope.kibanaPrivileges = getKibanaPrivileges(kibanaApplicationPrivilege, role, rbacApplication); + $scope.otherApplications = getOtherApplications(kibanaApplicationPrivilege, role, rbacApplication); + $scope.rolesHref = `#${ROLES_PATH}`; this.isNewRole = $route.current.params.name == null; @@ -103,6 +159,9 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { $scope.saveRole = (role) => { role.indices = role.indices.filter((index) => index.names.length); role.indices.forEach((index) => index.query || delete index.query); + + setApplicationPrivileges($scope.kibanaPrivileges, role, rbacApplication); + return role.$save() .then(() => toastNotifications.addSuccess('Updated role')) .then($scope.goToRoleList) @@ -156,12 +215,6 @@ routes.when(`${EDIT_ROLES_PATH}/:name?`, { } }; - $scope.hasPermission = (role, permission) => { - // TODO(legrego): faking until ES is implemented - const rolePermissions = role.applications || []; - return rolePermissions.find(rolePermission => permission.name === rolePermission.name); - }; - $scope.union = _.flow(_.union, _.compact); } }); diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/has_privileges.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/has_privileges.test.js.snap new file mode 100644 index 0000000000000..e191d4b14b6f0 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/has_privileges.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`throws error if missing version privilege and has login privilege 1`] = `"Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user."`; diff --git a/x-pack/plugins/security/server/lib/authorization/create_default_roles.js b/x-pack/plugins/security/server/lib/authorization/create_default_roles.js index 73837d0e490f7..933c36a98cf7a 100644 --- a/x-pack/plugins/security/server/lib/authorization/create_default_roles.js +++ b/x-pack/plugins/security/server/lib/authorization/create_default_roles.js @@ -8,9 +8,10 @@ * Licensed under the Elastic License; you may not use this file except in compliance with the Elastic License. */ import { getClient } from '../../../../../server/lib/get_client_shield'; +import { DEFAULT_RESOURCE } from '../../../common/constants'; -const createRoleIfDoesntExist = async (callCluster, name) => { +const createRoleIfDoesntExist = async (callCluster, { name, application, privilege }) => { try { await callCluster('shield.getRole', { name }); } catch (err) { @@ -23,7 +24,13 @@ const createRoleIfDoesntExist = async (callCluster, name) => { body: { cluster: [], index: [], - // application: [ { "privileges": [ "kibana:all" ], "resources": [ "*" ] } ] + applications: [ + { + application, + privileges: [ privilege ], + resources: [ DEFAULT_RESOURCE ] + } + ] } }); } @@ -40,8 +47,17 @@ export async function createDefaultRoles(server) { const callCluster = getClient(server).callWithInternalUser; - const createKibanaUserRole = createRoleIfDoesntExist(callCluster, `${application}_rbac_user`); - const createKibanaDashboardOnlyRole = createRoleIfDoesntExist(callCluster, `${application}_rbac_dashboard_only_user`); + const createKibanaUserRole = createRoleIfDoesntExist(callCluster, { + name: `${application}_rbac_user`, + application, + privilege: 'all' + }); + + const createKibanaDashboardOnlyRole = createRoleIfDoesntExist(callCluster, { + name: `${application}_rbac_dashboard_only_user`, + application, + privilege: 'read' + }); await Promise.all([createKibanaUserRole, createKibanaDashboardOnlyRole]); } diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.js new file mode 100644 index 0000000000000..bd91272ebbcf5 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.js @@ -0,0 +1,55 @@ +/* + * 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 { getClient } from '../../../../../server/lib/get_client_shield'; +import { DEFAULT_RESOURCE } from '../../../common/constants'; +import { getVersionPrivilege, getLoginPrivilege } from '../privileges'; + +const getMissingPrivileges = (resource, application, privilegeCheck) => { + const privileges = privilegeCheck.application[application][resource]; + return Object.keys(privileges).filter(key => privileges[key] === false); +}; + +export function hasPrivilegesWithServer(server) { + const callWithRequest = getClient(server).callWithRequest; + + const config = server.config(); + const kibanaVersion = config.get('pkg.version'); + const application = config.get('xpack.security.rbac.application'); + + return function hasPrivilegesWithRequest(request) { + return async function hasPrivileges(privileges) { + + const versionPrivilege = getVersionPrivilege(kibanaVersion); + const loginPrivilege = getLoginPrivilege(); + + const privilegeCheck = await callWithRequest(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application, + resources: [DEFAULT_RESOURCE], + privileges: [versionPrivilege, loginPrivilege, ...privileges] + }] + } + }); + + const success = privilegeCheck.has_all_requested; + const missingPrivileges = getMissingPrivileges(DEFAULT_RESOURCE, application, privilegeCheck); + + // We include the login privilege on all privileges, so the existence of it and not the version privilege + // lets us know that we're running in an incorrect configuration. Without the login privilege check, we wouldn't + // know whether the user just wasn't authorized for this instance of Kibana in general + if (missingPrivileges.includes(versionPrivilege) && !missingPrivileges.includes(loginPrivilege)) { + throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); + } + + return { + success, + missing: missingPrivileges + }; + }; + }; +} diff --git a/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js new file mode 100644 index 0000000000000..9d6b9580c47d8 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/has_privileges.test.js @@ -0,0 +1,231 @@ +/* + * 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 { hasPrivilegesWithServer } from './has_privileges'; +import { getClient } from '../../../../../server/lib/get_client_shield'; +import { DEFAULT_RESOURCE } from '../../../common/constants'; +import { getLoginPrivilege, getVersionPrivilege } from '../privileges'; + +jest.mock('../../../../../server/lib/get_client_shield', () => ({ + getClient: jest.fn() +})); + +let mockCallWithRequest; +beforeEach(() => { + mockCallWithRequest = jest.fn(); + getClient.mockReturnValue({ + callWithRequest: mockCallWithRequest + }); +}); + +const defaultVersion = 'default-version'; +const defaultApplication = 'default-application'; + +const createMockServer = ({ settings = {} } = {}) => { + const mockServer = { + config: jest.fn().mockReturnValue({ + get: jest.fn() + }) + }; + + const defaultSettings = { + 'pkg.version': defaultVersion, + 'xpack.security.rbac.application': defaultApplication + }; + + mockServer.config().get.mockImplementation(key => { + return key in settings ? settings[key] : defaultSettings[key]; + }); + + return mockServer; +}; + +const mockResponse = (hasAllRequested, privileges, application = defaultApplication) => { + mockCallWithRequest.mockImplementationOnce(async () => ({ + has_all_requested: hasAllRequested, + application: { + [application]: { + [DEFAULT_RESOURCE]: privileges + } + } + })); +}; + + +test(`calls shield.hasPrivileges with request`, async () => { + const mockServer = createMockServer(); + mockResponse(true, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: true, + }); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = {}; + const hasPrivileges = hasPrivilegesWithRequest(request); + await hasPrivileges(['foo']); + + expect(mockCallWithRequest).toHaveBeenCalledWith(request, expect.anything(), expect.anything()); +}); + +test(`calls shield.hasPrivileges with clientParams`, async () => { + const application = 'foo-application'; + const version = 'foo-version'; + const mockServer = createMockServer({ + settings: { + 'xpack.security.rbac.application': application, + 'pkg.version': version + } + }); + + mockResponse(true, { + [getVersionPrivilege(version)]: true, + [getLoginPrivilege()]: true, + foo: true, + }, application); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({}); + + const privilege = 'foo'; + await hasPrivileges([privilege]); + + const clientParams = mockCallWithRequest.mock.calls[0][2]; + const applicationParam = clientParams.body.applications[0]; + expect(applicationParam).toHaveProperty('application', application); + expect(applicationParam).toHaveProperty('resources', [DEFAULT_RESOURCE]); + expect(applicationParam).toHaveProperty('privileges'); + expect(applicationParam.privileges).toContain(privilege); +}); + +test(`includes version privilege when checking privileges`, async () => { + const mockServer = createMockServer(); + mockResponse(true, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: true, + }); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = {}; + const hasPrivileges = hasPrivilegesWithRequest(request); + await hasPrivileges(['foo']); + + const clientParams = mockCallWithRequest.mock.calls[0][2]; + const applicationParam = clientParams.body.applications[0]; + expect(applicationParam.privileges).toContain(getVersionPrivilege(defaultVersion)); +}); + +test(`includes login privilege when checking privileges`, async () => { + const mockServer = createMockServer(); + mockResponse(true, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: true, + }); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const request = {}; + const hasPrivileges = hasPrivilegesWithRequest(request); + await hasPrivileges(['foo']); + + const clientParams = mockCallWithRequest.mock.calls[0][2]; + const applicationParam = clientParams.body.applications[0]; + expect(applicationParam.privileges).toContain(getLoginPrivilege()); +}); + +test(`returns success when has_all_requested`, async () => { + const mockServer = createMockServer(); + mockResponse(true, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: true, + }); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({}); + const result = await hasPrivileges(['foo']); + expect(result.success).toBe(true); +}); + +test(`returns false success when has_all_requested is false`, async () => { + const mockServer = createMockServer(); + mockResponse(false, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: false, + }); + mockCallWithRequest.mockImplementationOnce(async () => ({ + has_all_requested: false, + application: { + [defaultApplication]: { + [DEFAULT_RESOURCE]: { + foo: false + } + } + } + })); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({}); + const result = await hasPrivileges(['foo']); + expect(result.success).toBe(false); +}); + +test(`returns missing privileges`, async () => { + const mockServer = createMockServer(); + mockResponse(false, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: false, + }); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({}); + const result = await hasPrivileges(['foo']); + expect(result.missing).toEqual(['foo']); +}); + +test(`excludes granted privileges from missing privileges`, async () => { + const mockServer = createMockServer(); + mockResponse(false, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: false, + bar: true, + }); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({}); + const result = await hasPrivileges(['foo']); + expect(result.missing).toEqual(['foo']); +}); + +test(`throws error if missing version privilege and has login privilege`, async () => { + const mockServer = createMockServer(); + mockResponse(false, { + [getVersionPrivilege(defaultVersion)]: false, + [getLoginPrivilege()]: true, + foo: true, + }); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({}); + await expect(hasPrivileges(['foo'])).rejects.toThrowErrorMatchingSnapshot(); +}); + +test(`doesn't throw error if missing version privilege and missing login privilege`, async () => { + const mockServer = createMockServer(); + mockResponse(false, { + [getVersionPrivilege(defaultVersion)]: true, + [getLoginPrivilege()]: true, + foo: true, + }); + + const hasPrivilegesWithRequest = hasPrivilegesWithServer(mockServer); + const hasPrivileges = hasPrivilegesWithRequest({}); + await hasPrivileges(['foo']); +}); diff --git a/x-pack/plugins/security/server/lib/privileges/equivalent_privileges.js b/x-pack/plugins/security/server/lib/privileges/equivalent_privileges.js new file mode 100644 index 0000000000000..35e2fd12af6d7 --- /dev/null +++ b/x-pack/plugins/security/server/lib/privileges/equivalent_privileges.js @@ -0,0 +1,11 @@ +/* + * 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 { isEqual } from 'lodash'; + +export function equivalentPrivileges(p1, p2) { + return isEqual(p1, p2); +} diff --git a/x-pack/plugins/security/server/lib/privileges/index.js b/x-pack/plugins/security/server/lib/privileges/index.js new file mode 100644 index 0000000000000..a270b8a60331e --- /dev/null +++ b/x-pack/plugins/security/server/lib/privileges/index.js @@ -0,0 +1,8 @@ +/* + * 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 { registerPrivilegesWithCluster } from './privilege_action_registry'; +export { getLoginPrivilege, getVersionPrivilege } from './privileges'; diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js index 86b1023fcb9b4..7d31b287fe664 100644 --- a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.js @@ -9,24 +9,29 @@ import { buildPrivilegeMap } from './privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; +import { equivalentPrivileges } from './equivalent_privileges'; export async function registerPrivilegesWithCluster(server) { - return; - const config = server.config(); const kibanaVersion = config.get('pkg.version'); const application = config.get('xpack.security.rbac.application'); - const privilegeActionMapping = buildPrivilegeMap(application, kibanaVersion); + const expectedPrivileges = buildPrivilegeMap(application, kibanaVersion); server.log(['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}`); const callCluster = getClient(server).callWithInternalUser; - // TODO(legrego) - non-working stub + // 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 }); + if (equivalentPrivileges(existingPrivileges, expectedPrivileges)) { + server.log(['security', 'debug'], `Kibana Privileges already registered with Elasticearch for ${application}`); + return; + } + + server.log(['security', 'debug'], `Updated Kibana Privileges with Elasticearch for ${application}`); await callCluster('shield.postPrivileges', { - body: { - [application]: privilegeActionMapping - } + body: expectedPrivileges }); } diff --git a/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js new file mode 100644 index 0000000000000..2fcfd5c2caaa0 --- /dev/null +++ b/x-pack/plugins/security/server/lib/privileges/privilege_action_registry.test.js @@ -0,0 +1,209 @@ +/* + * 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 { registerPrivilegesWithCluster } from './privilege_action_registry'; +import { getClient } from '../../../../../server/lib/get_client_shield'; +import { buildPrivilegeMap } from './privileges'; +jest.mock('../../../../../server/lib/get_client_shield', () => ({ + getClient: jest.fn(), +})); +jest.mock('./privileges', () => ({ + buildPrivilegeMap: jest.fn(), +})); + +const registerPrivilegesWithClusterTest = (description, { settings = {}, expectedPrivileges, existingPrivileges, assert }) => { + const registerMockCallWithInternalUser = () => { + const callWithInternalUser = jest.fn(); + getClient.mockReturnValue({ + callWithInternalUser, + }); + return callWithInternalUser; + }; + + const defaultVersion = 'default-version'; + const defaultApplication = 'default-application'; + + const createMockServer = () => { + const mockServer = { + config: jest.fn().mockReturnValue({ + get: jest.fn(), + }), + log: jest.fn(), + }; + + const defaultSettings = { + 'pkg.version': defaultVersion, + 'xpack.security.rbac.application': defaultApplication, + }; + + mockServer.config().get.mockImplementation(key => { + return key in settings ? settings[key] : defaultSettings[key]; + }); + + return mockServer; + }; + + const createExpectUpdatedPrivileges = (mockServer, mockCallWithInternalUser, privileges) => { + return () => { + expect(mockCallWithInternalUser).toHaveBeenCalledTimes(2); + expect(mockCallWithInternalUser).toHaveBeenCalledWith('shield.getPrivilege', { + privilege: defaultApplication, + }); + expect(mockCallWithInternalUser).toHaveBeenCalledWith( + 'shield.postPrivileges', + { + body: privileges, + } + ); + + const application = settings['xpack.security.rbac.application'] || defaultApplication; + expect(mockServer.log).toHaveBeenCalledWith( + ['security', 'debug'], + `Registering Kibana Privileges with Elasticsearch for ${application}` + ); + expect(mockServer.log).toHaveBeenCalledWith( + ['security', 'debug'], + `Updated Kibana Privileges with Elasticearch for ${application}` + ); + }; + }; + + const createExpectDidntUpdatePrivileges = (mockServer, mockCallWithInternalUser) => { + return () => { + expect(mockCallWithInternalUser).toHaveBeenCalledTimes(1); + expect(mockCallWithInternalUser).toHaveBeenLastCalledWith('shield.getPrivilege', { + privilege: defaultApplication + }); + + const application = settings['xpack.security.rbac.application'] || defaultApplication; + expect(mockServer.log).toHaveBeenCalledWith( + ['security', 'debug'], + `Registering Kibana Privileges with Elasticsearch for ${application}` + ); + expect(mockServer.log).toHaveBeenCalledWith( + ['security', 'debug'], + `Kibana Privileges already registered with Elasticearch for ${application}` + ); + }; + }; + + test(description, async () => { + const mockServer = createMockServer(); + const mockCallWithInternalUser = registerMockCallWithInternalUser(); + mockCallWithInternalUser.mockImplementationOnce(async () => (existingPrivileges)); + buildPrivilegeMap.mockReturnValue(expectedPrivileges); + + await registerPrivilegesWithCluster(mockServer); + + assert({ + expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockServer, mockCallWithInternalUser, expectedPrivileges), + expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges(mockServer, mockCallWithInternalUser), + mocks: { + buildPrivilegeMap + } + }); + }); +}; + +registerPrivilegesWithClusterTest(`passes application and kibanaVersion to buildPrivilegeMap`, { + settings: { + 'pkg.version': 'foo-version', + 'xpack.security.rbac.application': 'foo-application', + }, + assert: ({ mocks }) => { + expect(mocks.buildPrivilegeMap).toHaveBeenCalledWith('foo-application', 'foo-version'); + }, +}); + +registerPrivilegesWithClusterTest(`updates privileges when simple top-level privileges don't match`, { + expectedPrivileges: { + expected: true + }, + existingPrivileges: { + expected: false + }, + assert: ({ expectUpdatedPrivileges }) => { + expectUpdatedPrivileges(); + } +}); + +registerPrivilegesWithClusterTest(`updates privileges when nested privileges don't match`, { + expectedPrivileges: { + kibana: { + expected: true + } + }, + existingPrivileges: { + kibana: { + expected: false + } + }, + assert: ({ expectUpdatedPrivileges }) => { + expectUpdatedPrivileges(); + } +}); + +registerPrivilegesWithClusterTest(`updates privileges when nested privileges arrays don't match`, { + expectedPrivileges: { + kibana: { + expected: ['one', 'two'] + } + }, + existingPrivileges: { + kibana: { + expected: ['one'] + } + }, + assert: ({ expectUpdatedPrivileges }) => { + expectUpdatedPrivileges(); + } +}); + +registerPrivilegesWithClusterTest(`updates privileges when nested propertry array values are reordered`, { + expectedPrivileges: { + kibana: { + foo: ['one', 'two'] + } + }, + existingPrivileges: { + kibana: { + foo: ['two', 'one'] + } + }, + assert: ({ expectUpdatedPrivileges }) => { + expectUpdatedPrivileges(); + } +}); + +registerPrivilegesWithClusterTest(`doesn't update privileges when simple top-level privileges match`, { + expectedPrivileges: { + expected: true + }, + existingPrivileges: { + expected: true + }, + assert: ({ expectDidntUpdatePrivileges }) => { + expectDidntUpdatePrivileges(); + } +}); + +registerPrivilegesWithClusterTest(`doesn't update privileges when nested properties are reordered`, { + expectedPrivileges: { + kibana: { + foo: true, + bar: false + } + }, + existingPrivileges: { + kibana: { + bar: false, + foo: true + } + }, + assert: ({ expectDidntUpdatePrivileges }) => { + expectDidntUpdatePrivileges(); + } +}); diff --git a/x-pack/plugins/security/server/lib/privileges/privileges.js b/x-pack/plugins/security/server/lib/privileges/privileges.js index becfaa5aee78d..e05be3117f934 100644 --- a/x-pack/plugins/security/server/lib/privileges/privileges.js +++ b/x-pack/plugins/security/server/lib/privileges/privileges.js @@ -7,50 +7,46 @@ /*! 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 buildPrivilegeMap(application, kibanaVersion) { - const readSavedObjectsPrivileges = buildSavedObjectsReadPrivileges(''); +export function getVersionPrivilege(kibanaVersion) { + return `version:${kibanaVersion}`; +} - const commonMetadata = { - kibanaVersion - }; +export function getLoginPrivilege() { + return `action:login`; +} - function getActionName(...nameParts) { - return nameParts.join('/'); - } +export function buildPrivilegeMap(application, kibanaVersion) { + const readSavedObjectsPrivileges = buildSavedObjectsReadPrivileges(); - const privilegeActionMap = []; + const privilegeActions = {}; - privilegeActionMap.push({ + privilegeActions.all = { application, name: 'all', - actions: [getActionName('*')], - metadata: { - ...commonMetadata, - displayName: 'all' - } - }); - - privilegeActionMap.push({ + actions: [getVersionPrivilege(kibanaVersion), getLoginPrivilege(), 'action:*'], + metadata: {} + }; + + privilegeActions.read = { application, name: 'read', - actions: [...readSavedObjectsPrivileges], - metadata: { - ...commonMetadata, - displayName: 'read' - } - }); - - return privilegeActionMap; + actions: [getVersionPrivilege(kibanaVersion), getLoginPrivilege(), ...readSavedObjectsPrivileges], + metadata: {} + }; + + return { + [application]: privilegeActions + }; } -function buildSavedObjectsReadPrivileges(prefix) { +function buildSavedObjectsReadPrivileges() { const readActions = ['get', 'mget', 'search']; - return buildSavedObjectsPrivileges(prefix, readActions); + return buildSavedObjectsPrivileges(readActions); } -function buildSavedObjectsPrivileges(prefix, actions) { - const objectTypes = ['dashboard', 'visualization', 'search']; +function buildSavedObjectsPrivileges(actions) { + const objectTypes = ['config', 'dashboard', 'index-pattern', 'search', 'visualization', 'graph-workspace']; return objectTypes - .map(type => actions.map(action => `${prefix}/${type}/${action}`)) + .map(type => actions.map(action => `action:saved-objects/${type}/${action}`)) .reduce((acc, types) => [...acc, ...types], []); } diff --git a/x-pack/plugins/security/server/lib/saved_object_client_interceptor.js b/x-pack/plugins/security/server/lib/saved_object_client_interceptor.js deleted file mode 100644 index 9ad4b71b44594..0000000000000 --- a/x-pack/plugins/security/server/lib/saved_object_client_interceptor.js +++ /dev/null @@ -1,42 +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. - */ - -/*! 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 { checkUserPermission } from './check_user_permission'; - -export function checkSavedObjectPermissions() { - return (request) => ({ - method: 'all', - intercept: async (client, method, type) => { - const action = `kibana:/savedobject/${method}/${type}`; - - const { credentials } = request.auth; - - // TODO(larry): dashboard_mode_auth_scope.js uses request's SOC before the user is attached to the request. Chicken and egg... - if (!credentials) { - console.warn( - `!!!!DANGER!!!! No credentials on request (${request.path})! Unable to authorize Saved Object Client request. See trace below:` - ); - console.trace(); - return; - } - - const { roles } = request.auth.credentials; - - const fakeAccessCheck = roles.indexOf('kibana_ols_user') >= 0; - - const hasPermission = await checkUserPermission(action, fakeAccessCheck || true); - console.log('hasPermission returned', hasPermission); - - if (!hasPermission) { - const errorMessage = `Unauthorized: User must have "kibana:all" permission to perform this action.`; - throw client.errors.decorateForbiddenError(new Error(), errorMessage); - } - } - }); -} diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js index 229570f401010..65f0ce3ff64e8 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_options_builder.js @@ -7,13 +7,13 @@ /*! 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 secureSavedObjectsClientOptionsBuilder(server, options) { +export function secureSavedObjectsClientOptionsBuilder(server, hasPrivilegesWithRequest, options) { const adminCluster = server.plugins.elasticsearch.getCluster('admin'); const { callWithInternalUser } = adminCluster; return { ...options, - application: server.config().get('xpack.security.rbac.application'), - callCluster: callWithInternalUser + callCluster: callWithInternalUser, + hasPrivilegesWithRequest }; } diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js index 796ae9151e3f0..8793985233c35 100644 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js @@ -4,39 +4,31 @@ * you may not use this file except in compliance with the Elastic License. */ -/*! 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 Boom from 'boom'; -import { getClient } from '../../../../../server/lib/get_client_shield'; +import { get, uniq } from 'lodash'; export class SecureSavedObjectsClient { constructor(options) { const { - server, request, + hasPrivilegesWithRequest, baseClient, - application, } = options; this.errors = baseClient.errors; this._client = baseClient; - this._application = application; - this._callCluster = getClient(server).callWithRequest; - this._request = request; + this._hasPrivileges = hasPrivilegesWithRequest(request); } async create(type, attributes = {}, options = {}) { - await this._performAuthorizationCheck(type, 'create', attributes, options); + await this._performAuthorizationCheck(type, 'create'); return await this._client.create(type, attributes, options); } async bulkCreate(objects, options = {}) { - for (const object of objects) { - await this._performAuthorizationCheck(object.type, 'create', object.attributes); - } + const types = uniq(objects.map(o => o.type)); + await this._performAuthorizationCheck(types, 'create'); return await this._client.bulkCreate(objects, options); } @@ -48,15 +40,14 @@ export class SecureSavedObjectsClient { } async find(options = {}) { - // TODO(legrego) - need to constrain which types users can search for... - await this._performAuthorizationCheck(null, 'search', null, options); + await this._performAuthorizationCheck(options.type, 'search'); return await this._client.find(options); } async bulkGet(objects = []) { for (const object of objects) { - await this._performAuthorizationCheck(object.type, 'mget', object.attributes); + await this._performAuthorizationCheck(object.type, 'mget'); } return await this._client.bulkGet(objects); @@ -69,28 +60,26 @@ export class SecureSavedObjectsClient { } async update(type, id, attributes, options = {}) { - await this._performAuthorizationCheck(type, attributes, options); + await this._performAuthorizationCheck(type, 'update'); return await this._client.update(type, id, attributes, options); } - async _performAuthorizationCheck(type, action, attributes = {}, options = {}) { // eslint-disable-line no-unused-vars - return; - // TODO(legrego) use ES Custom Privilege API once implemented. - const kibanaAction = `saved-objects/${type}/${action}`; - - const privilegeCheck = await this._callCluster(this._request, 'shield.hasPrivileges', { - body: { - applications: [{ - application: this._application, - resources: ['default'], - privileges: [kibanaAction] - }] - } - }); - - if (!privilegeCheck.has_all_requested) { - throw Boom.unauthorized(`User ${privilegeCheck.username} is not authorized to ${action} objects of type ${type}`); + async _performAuthorizationCheck(typeOrTypes, action) { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + const actions = types.map(type => `action:saved-objects/${type}/${action}`); + + let result; + try { + result = await this._hasPrivileges(actions); + } catch(error) { + const { reason } = get(error, 'body.error', {}); + throw this._client.errors.decorateGeneralError(error, reason); + } + + if (!result.success) { + const msg = `Unable to ${action} ${types.join(',')}, missing ${result.missing.join(',')}`; + throw this._client.errors.decorateForbiddenError(new Error(msg)); } } } diff --git a/x-pack/plugins/security/server/lib/validate_config.js b/x-pack/plugins/security/server/lib/validate_config.js index 0b9061389a92c..d1f80cb65b7ac 100644 --- a/x-pack/plugins/security/server/lib/validate_config.js +++ b/x-pack/plugins/security/server/lib/validate_config.js @@ -7,7 +7,6 @@ const crypto = require('crypto'); const isDefault = (config, key) => { - console.log(config.getDefault(key), config.get(key)); return config.getDefault(key) === config.get(key); }; diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js index 7d3ac9ead2e4e..1e6ac21fb54e0 100644 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ b/x-pack/plugins/security/server/routes/api/v1/privileges.js @@ -21,8 +21,8 @@ export function initPrivilegesApi(server) { method: 'GET', path: '/api/security/v1/privileges', handler(request, reply) { - - reply(buildPrivilegeMap(application, kibanaVersion)); + const privileges = buildPrivilegeMap(application, kibanaVersion); + reply(Object.values(privileges[application])); } }); } diff --git a/x-pack/plugins/security/server/routes/api/v1/roles/contains_other_applications.js b/x-pack/plugins/security/server/routes/api/v1/roles/contains_other_applications.js deleted file mode 100644 index 43a4e57f9d1a4..0000000000000 --- a/x-pack/plugins/security/server/routes/api/v1/roles/contains_other_applications.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/*! 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 containsOtherApplications(role, ourApplication) { - if (!role.applications || role.applications.length === 0) { - return false; - } - - return role.applications.some(x => x.application !== ourApplication); -} diff --git a/x-pack/plugins/security/server/routes/api/v1/roles/contains_other_applications.test.js b/x-pack/plugins/security/server/routes/api/v1/roles/contains_other_applications.test.js deleted file mode 100644 index e7ea134fad5b9..0000000000000 --- a/x-pack/plugins/security/server/routes/api/v1/roles/contains_other_applications.test.js +++ /dev/null @@ -1,66 +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. - */ - -/*! 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 { containsOtherApplications } from "./contains_other_applications"; - -test(`returns true for roles that grant privileges to other Kibanas`, () => { - const roles = [ - { - _expectedResult: false, - cluster: [], - indices: [ - { - names: ['logstash-*'], - privileges: ['read'] - } - ], - run_as: [], - metadata: { - _reserved: true - }, - transient_metadata: { - enabled: true - }, - name: 'logstash_reader' - }, - { - _expectedResult: false, - cluster: [], - indices: [], - applications: [ - { application: 'kibana', privileges: ['read'], resources: ['*'] } - ], - run_as: [], - metadata: {}, - transient_metadata: { - enabled: true - }, - name: 'kibana_user' - }, - { - _expectedResult: true, - cluster: [], - indices: [], - applications: [ - { application: 'other-kibana', privileges: ['read'], resources: ['*'] } - ], - run_as: [], - metadata: {}, - transient_metadata: { - enabled: true - }, - name: 'kibana_user' - } - ]; - - roles.forEach(role => { - const result = containsOtherApplications(role, 'kibana'); - expect(result).toEqual(role._expectedResult); - }); -}); diff --git a/x-pack/plugins/security/server/routes/api/v1/roles/index.js b/x-pack/plugins/security/server/routes/api/v1/roles/index.js index d965f561510d7..8e0e114798c3c 100644 --- a/x-pack/plugins/security/server/routes/api/v1/roles/index.js +++ b/x-pack/plugins/security/server/routes/api/v1/roles/index.js @@ -10,7 +10,6 @@ import { getClient } from '../../../../../../../server/lib/get_client_shield'; import { roleSchema } from '../../../../lib/role_schema'; import { wrapError } from '../../../../lib/errors'; import { routePreCheckLicense } from '../../../../lib/route_pre_check_license'; -import { containsOtherApplications } from './contains_other_applications'; export function initRolesApi(server) { const callWithRequest = getClient(server).callWithRequest; @@ -20,16 +19,10 @@ export function initRolesApi(server) { method: 'GET', path: '/api/security/v1/roles', handler(request, reply) { - const config = server.config(); - return callWithRequest(request, 'shield.getRole').then( (response) => { - const application = config.get('xpack.security.rbac.application'); - const roles = _.map(response, (role, name) => { - const hasUnsupportedCustomPrivileges = containsOtherApplications(role, application); - - return _.assign(role, { name, hasUnsupportedCustomPrivileges }); + return _.assign(role, { name }); }); return reply(roles); @@ -64,8 +57,7 @@ export function initRolesApi(server) { path: '/api/security/v1/roles/{name}', handler(request, reply) { const name = request.params.name; - // TODO(legrego) - temporarily remove applications from role until ES API is implemented - const body = _.omit(request.payload, ['name', 'applications', 'hasUnsupportedCustomPrivileges']); + const body = _.omit(request.payload, 'name'); return callWithRequest(request, 'shield.putRole', { name, body }).then( () => reply(request.payload),