From 52dd7e17c4ee1bcada352b142532ca534002e8d5 Mon Sep 17 00:00:00 2001 From: Elena Shostak <165678770+elena-shostak@users.noreply.github.com> Date: Thu, 12 Dec 2024 23:55:04 +0100 Subject: [PATCH] [Authz] Operator privileges (#196583) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds support for explicit indication whether endpoint is restricted to operator only users. ### Context 1. If user has [all operator privileges](https://github.com/elastic/elasticsearch/blob/main/x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/operator/DefaultOperatorOnlyRegistry.java#L35-#L53) granted, but is not listed as operator in `operator_users.yml`, ES would throw an unauthorized error. 2. If user is listed as operator in `operator_users.yml`, but doesn't have necessary privileges granted, ES would throw an unauthorized error. 3. It’s not possible to determine if a user is operator via any ES API, i.e. `_has_privileges`. 4. If operator privileges are disabled we skip the the check for it, that's why we require to explicitly specify additional privileges to ensure that the route is protected even when operator privileges are disabled. ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios __Relates: https://github.com/elastic/kibana/issues/196271__ ### How to test 1. Add your user to the operators list https://github.com/elastic/kibana/blob/1bd81449242a1ab57e82c211808753e82f25c92c/packages/kbn-es/src/serverless_resources/operator_users.yml#L4 or use existing user from the list to log in. 2. Run ES and Kibana serverless 3. Change any endpoint or create a new one with the following security config ``` security: { authz: { requiredPrivileges: [ReservedPrivilegesSet.operator], }, }, ``` 4. Check with enabled and disabled operator privileges (set `xpack.security.operator_privileges.enabled`) ## Release Note Added support for explicit indication whether endpoint is restricted to operator only users at the route definition level. --------- Co-authored-by: Elastic Machine --- dev_docs/key_concepts/api_authorization.mdx | 34 +++++- .../security_route_config_validator.test.ts | 63 +++++++++- .../src/security_route_config_validator.ts | 34 +++++- .../src/authentication/authenticated_user.ts | 5 + .../security/plugin_types_server/index.ts | 1 + .../src/authorization/es_security_config.ts | 13 ++ .../src/authorization/index.ts | 1 + .../plugin_types_server/src/plugin.ts | 2 +- .../authorization/api_authorization.test.ts | 113 +++++++++++++++--- .../server/authorization/api_authorization.ts | 78 ++++++++++-- .../authorization_service.test.ts | 13 ++ .../authorization/authorization_service.tsx | 14 ++- x-pack/plugins/security/server/plugin.test.ts | 14 +++ x-pack/plugins/security/server/plugin.ts | 4 +- 14 files changed, 354 insertions(+), 35 deletions(-) create mode 100644 x-pack/packages/security/plugin_types_server/src/authorization/es_security_config.ts diff --git a/dev_docs/key_concepts/api_authorization.mdx b/dev_docs/key_concepts/api_authorization.mdx index cda6ad5de21ce..5615a20d0f4b5 100644 --- a/dev_docs/key_concepts/api_authorization.mdx +++ b/dev_docs/key_concepts/api_authorization.mdx @@ -102,6 +102,38 @@ router.get({ }, handler); ``` +### Configuring operator and superuser privileges +We have two special predefined privilege sets that can be used in security configuration: +1. Operator +```ts +router.get({ + path: '/api/path', + security: { + authz: { + requiredPrivileges: [ReservedPrivilegesSet.operator, ''], + }, + }, + ... +}, handler); +``` +Operator privileges check is enforced only if operator privileges are enabled in the Elasticsearch configuration. +For more information on operator privileges, refer to the [Operator privileges documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/operator-privileges.html). +If operator privileges are disabled, we skip the check for it, so the only privilege checked from the example above is ``. +Operator privileges cannot be used as standalone, it is required to explicitly specify additional privileges in the configuration to ensure that the route is protected even when operator privileges are disabled. + +2. Superuser +```ts +router.get({ + path: '/api/path', + security: { + authz: { + requiredPrivileges: [ReservedPrivilegesSet.superuser], + }, + }, + ... +}, handler); +``` + ### Opting out of authorization for specific routes **Before migration:** ```ts @@ -291,7 +323,7 @@ GET /api/oas?pathStartsWith=/your/api/path ## Migrating from `access` tags to `security` configuration We aim to use the same privileges that are currently defined with tags `access:`. To assist with this migration, we have created eslint rule `no_deprecated_authz_config`, that will automatically convert your `access` tags to the new `security` configuration. -It scans route definitions and converts `access` tags to the new `requiredPriviliges` configuration. +It scans route definitions and converts `access` tags to the new `requiredPrivileges` configuration. Note: The rule is disabled by default to avoid automatic migrations without an oversight. You can perform migrations by running: diff --git a/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.test.ts b/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.test.ts index f10d2cb3b3ac4..a0cadaacfbf7b 100644 --- a/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.test.ts +++ b/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.test.ts @@ -220,6 +220,16 @@ describe('RouteSecurity validation', () => { expect(() => validRouteSecurity(routeSecurity)).not.toThrow(); }); + it('should pass validation when operator privileges are combined with superuser', () => { + const routeSecurity = { + authz: { + requiredPrivileges: [ReservedPrivilegesSet.operator, ReservedPrivilegesSet.superuser], + }, + }; + + expect(() => validRouteSecurity(routeSecurity)).not.toThrow(); + }); + it('should fail validation when anyRequired and allRequired have the same values', () => { const invalidRouteSecurity = { authz: { @@ -263,6 +273,20 @@ describe('RouteSecurity validation', () => { ); }); + it('should fail validation when allRequired has duplicate entries', () => { + const invalidRouteSecurity = { + authz: { + requiredPrivileges: [ + { anyRequired: ['privilege4', 'privilege5'], allRequired: ['privilege1', 'privilege1'] }, + ], + }, + }; + + expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot( + `"[authz.requiredPrivileges]: allRequired privileges must contain unique values"` + ); + }); + it('should fail validation when anyRequired has duplicates in multiple privilege entries', () => { const invalidRouteSecurity = { authz: { @@ -289,7 +313,7 @@ describe('RouteSecurity validation', () => { }; expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot( - `"[authz.requiredPrivileges]: Combining superuser with other privileges is redundant, superuser privileges set can be only used as a standalone privilege."` + `"[authz.requiredPrivileges]: Using superuser privileges in anyRequired is not allowed"` ); }); @@ -304,4 +328,41 @@ describe('RouteSecurity validation', () => { `"[authz.requiredPrivileges]: Combining superuser with other privileges is redundant, superuser privileges set can be only used as a standalone privilege."` ); }); + + it('should fail validation when anyRequired has operator privileges set', () => { + const invalidRouteSecurity = { + authz: { + requiredPrivileges: [ + { anyRequired: ['privilege1', 'privilege2'], allRequired: ['privilege4'] }, + { anyRequired: ['privilege5', ReservedPrivilegesSet.operator] }, + ], + }, + }; + + expect(() => validRouteSecurity(invalidRouteSecurity)).toThrowErrorMatchingInlineSnapshot( + `"[authz.requiredPrivileges]: Using operator privileges in anyRequired is not allowed"` + ); + }); + + it('should fail validation when operator privileges set is used as standalone', () => { + expect(() => + validRouteSecurity({ + authz: { + requiredPrivileges: [{ allRequired: [ReservedPrivilegesSet.operator] }], + }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authz.requiredPrivileges]: Operator privilege requires at least one additional non-operator privilege to be defined"` + ); + + expect(() => + validRouteSecurity({ + authz: { + requiredPrivileges: [ReservedPrivilegesSet.operator], + }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[authz.requiredPrivileges]: Operator privilege requires at least one additional non-operator privilege to be defined"` + ); + }); }); diff --git a/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.ts b/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.ts index 65073f9a66ec6..ec54c5e61ce0d 100644 --- a/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.ts +++ b/packages/core/http/core-http-router-server-internal/src/security_route_config_validator.ts @@ -50,15 +50,33 @@ const requiredPrivilegesSchema = schema.arrayOf( } }); + if (anyRequired.includes(ReservedPrivilegesSet.superuser)) { + return 'Using superuser privileges in anyRequired is not allowed'; + } + + const hasSuperuserInAllRequired = allRequired.includes(ReservedPrivilegesSet.superuser); + const hasOperatorInAllRequired = allRequired.includes(ReservedPrivilegesSet.operator); + // Combining superuser with other privileges is redundant. // If user is a superuser, they inherently have access to all the privileges that may come with other roles. + // The exception is when superuser and operator are the only required privileges. if ( - anyRequired.includes(ReservedPrivilegesSet.superuser) || - (allRequired.includes(ReservedPrivilegesSet.superuser) && allRequired.length > 1) + hasSuperuserInAllRequired && + allRequired.length > 1 && + !(hasOperatorInAllRequired && allRequired.length === 2) ) { return 'Combining superuser with other privileges is redundant, superuser privileges set can be only used as a standalone privilege.'; } + // Operator privilege requires at least one additional non-operator privilege to be defined, that's why it's not allowed in anyRequired. + if (anyRequired.includes(ReservedPrivilegesSet.operator)) { + return 'Using operator privileges in anyRequired is not allowed'; + } + + if (hasOperatorInAllRequired && allRequired.length === 1) { + return 'Operator privilege requires at least one additional non-operator privilege to be defined'; + } + if (anyRequired.length && allRequired.length) { for (const privilege of anyRequired) { if (allRequired.includes(privilege)) { @@ -68,12 +86,20 @@ const requiredPrivilegesSchema = schema.arrayOf( } if (anyRequired.length) { - const uniquePrivileges = new Set([...anyRequired]); + const uniqueAnyPrivileges = new Set([...anyRequired]); - if (anyRequired.length !== uniquePrivileges.size) { + if (anyRequired.length !== uniqueAnyPrivileges.size) { return 'anyRequired privileges must contain unique values'; } } + + if (allRequired.length) { + const uniqueAllPrivileges = new Set([...allRequired]); + + if (allRequired.length !== uniqueAllPrivileges.size) { + return 'allRequired privileges must contain unique values'; + } + } }, minSize: 1, } diff --git a/packages/core/security/core-security-common/src/authentication/authenticated_user.ts b/packages/core/security/core-security-common/src/authentication/authenticated_user.ts index 4aa871125052b..d80ff8f434a4f 100644 --- a/packages/core/security/core-security-common/src/authentication/authenticated_user.ts +++ b/packages/core/security/core-security-common/src/authentication/authenticated_user.ts @@ -60,4 +60,9 @@ export interface AuthenticatedUser extends User { * User profile ID of this user. */ profile_uid?: string; + + /** + * Indicates whether user is an operator. + */ + operator?: boolean; } diff --git a/x-pack/packages/security/plugin_types_server/index.ts b/x-pack/packages/security/plugin_types_server/index.ts index 2b46fa0146a2a..4f213fc6c9920 100644 --- a/x-pack/packages/security/plugin_types_server/index.ts +++ b/x-pack/packages/security/plugin_types_server/index.ts @@ -50,6 +50,7 @@ export type { CheckUserProfilesPrivileges, AuthorizationMode, AuthorizationServiceSetup, + EsSecurityConfig, } from './src/authorization'; export type { SecurityPluginSetup, SecurityPluginStart } from './src/plugin'; export type { diff --git a/x-pack/packages/security/plugin_types_server/src/authorization/es_security_config.ts b/x-pack/packages/security/plugin_types_server/src/authorization/es_security_config.ts new file mode 100644 index 0000000000000..3f4d244d5fb9c --- /dev/null +++ b/x-pack/packages/security/plugin_types_server/src/authorization/es_security_config.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Client } from '@elastic/elasticsearch'; + +type XpackUsageResponse = Awaited>; +type XpackUsageSecurity = XpackUsageResponse['security']; + +export type EsSecurityConfig = Pick; diff --git a/x-pack/packages/security/plugin_types_server/src/authorization/index.ts b/x-pack/packages/security/plugin_types_server/src/authorization/index.ts index c48e797dc1d1b..0ffa0900fa2d3 100644 --- a/x-pack/packages/security/plugin_types_server/src/authorization/index.ts +++ b/x-pack/packages/security/plugin_types_server/src/authorization/index.ts @@ -43,6 +43,7 @@ export type { PrivilegeDeprecationsRolesByFeatureIdResponse, } from './deprecations'; export type { AuthorizationMode } from './mode'; +export type { EsSecurityConfig } from './es_security_config'; export { GLOBAL_RESOURCE } from './constants'; export { elasticsearchRoleSchema, getKibanaRoleSchema } from './role_schema'; diff --git a/x-pack/packages/security/plugin_types_server/src/plugin.ts b/x-pack/packages/security/plugin_types_server/src/plugin.ts index 7d37935ab760a..c834dc46225f3 100644 --- a/x-pack/packages/security/plugin_types_server/src/plugin.ts +++ b/x-pack/packages/security/plugin_types_server/src/plugin.ts @@ -45,7 +45,7 @@ export interface SecurityPluginStart { /** * Authorization services to manage and access the permissions a particular user has. */ - authz: AuthorizationServiceSetup; + authz: Omit; /** * User profiles services to retrieve user profiles. * 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 0181c98d6f1b1..d2db2a535b1d1 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.test.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.test.ts @@ -20,7 +20,11 @@ import { authorizationMock } from './index.mock'; describe('initAPIAuthorization', () => { test(`protected route when "mode.useRbacForRequest()" returns false continues`, async () => { const mockHTTPSetup = coreMock.createSetup().http; - const mockAuthz = authorizationMock.create(); + const mockAuthz = { + ...authorizationMock.create(), + getCurrentUser: jest.fn(), + getSecurityConfig: jest.fn(), + }; initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; @@ -44,7 +48,11 @@ describe('initAPIAuthorization', () => { test(`unprotected route when "mode.useRbacForRequest()" returns true continues`, async () => { const mockHTTPSetup = coreMock.createSetup().http; - const mockAuthz = authorizationMock.create(); + const mockAuthz = { + ...authorizationMock.create(), + getCurrentUser: jest.fn(), + getSecurityConfig: jest.fn(), + }; initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; @@ -68,7 +76,11 @@ describe('initAPIAuthorization', () => { test(`protected route when "mode.useRbacForRequest()" returns true and user is authorized continues`, async () => { const mockHTTPSetup = coreMock.createSetup().http; - const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + const mockAuthz = { + ...authorizationMock.create({ version: '1.0.0-zeta1' }), + getCurrentUser: jest.fn(), + getSecurityConfig: jest.fn(), + }; initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; @@ -105,7 +117,11 @@ describe('initAPIAuthorization', () => { test(`protected route when "mode.useRbacForRequest()" returns true and user isn't authorized responds with a 403`, async () => { const mockHTTPSetup = coreMock.createSetup().http; - const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + const mockAuthz = { + ...authorizationMock.create({ version: '1.0.0-zeta1' }), + getCurrentUser: jest.fn(), + getSecurityConfig: jest.fn(), + }; initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; @@ -146,8 +162,10 @@ describe('initAPIAuthorization', () => { { security, kibanaPrivilegesResponse, + kibanaCurrentUserResponse, kibanaPrivilegesRequestActions, asserts, + esXpackSecurityUsageResponse, }: { security?: RouteSecurity; kibanaPrivilegesResponse?: { @@ -155,6 +173,10 @@ describe('initAPIAuthorization', () => { hasAllRequested?: boolean; }; kibanaPrivilegesRequestActions?: string[]; + kibanaCurrentUserResponse?: { operator: boolean }; + esXpackSecurityUsageResponse?: { + operator_privileges: { enabled: boolean; available: boolean }; + }; asserts: { forbidden?: boolean; authzResult?: Record; @@ -164,7 +186,11 @@ describe('initAPIAuthorization', () => { ) => { test(description, async () => { const mockHTTPSetup = coreMock.createSetup().http; - const mockAuthz = authorizationMock.create({ version: '1.0.0-zeta1' }); + const mockAuthz = { + ...authorizationMock.create({ version: '1.0.0-zeta1' }), + getCurrentUser: jest.fn(), + getSecurityConfig: jest.fn(), + }; initAPIAuthorization(mockHTTPSetup, mockAuthz, loggingSystemMock.create().get()); const [[postAuthHandler]] = mockHTTPSetup.registerOnPostAuth.mock.calls; @@ -185,6 +211,8 @@ describe('initAPIAuthorization', () => { const mockPostAuthToolkit = httpServiceMock.createOnPostAuthToolkit(); const mockCheckPrivileges = jest.fn().mockReturnValue(kibanaPrivilegesResponse); + mockAuthz.getCurrentUser.mockReturnValue(kibanaCurrentUserResponse); + mockAuthz.getSecurityConfig.mockResolvedValue(esXpackSecurityUsageResponse); 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 @@ -356,28 +384,77 @@ describe('initAPIAuthorization', () => { ); testSecurityConfig( - `protected route returns forbidden if user has allRequired AND NONE of anyRequired privileges requested`, + `protected route returns "authzResult" if user has operator privileges requested and user is operator`, { security: { authz: { - requiredPrivileges: [ - { - allRequired: ['privilege1'], - anyRequired: ['privilege2', 'privilege3'], - }, - ], + requiredPrivileges: [ReservedPrivilegesSet.operator], + }, + }, + kibanaCurrentUserResponse: { operator: true }, + esXpackSecurityUsageResponse: { operator_privileges: { enabled: true, available: true } }, + asserts: { + authzResult: { + operator: true, + }, + }, + } + ); + + testSecurityConfig( + `protected route returns "authzResult" if user has requested operator privileges and operator privileges are disabled`, + { + security: { + authz: { + requiredPrivileges: [ReservedPrivilegesSet.operator, 'privilege1'], }, }, - kibanaPrivilegesRequestActions: ['privilege1', 'privilege2', 'privilege3'], kibanaPrivilegesResponse: { privileges: { - kibana: [ - { privilege: 'api:privilege1', authorized: true }, - { privilege: 'api:privilege2', authorized: false }, - { privilege: 'api:privilege3', authorized: false }, - ], + kibana: [{ privilege: 'api:privilege1', authorized: true }], + }, + }, + kibanaCurrentUserResponse: { operator: false }, + esXpackSecurityUsageResponse: { operator_privileges: { enabled: false, available: false } }, + asserts: { + authzResult: { + privilege1: true, + }, + }, + } + ); + + testSecurityConfig( + `protected route returns forbidden if user operator privileges are disabled and user doesn't have additional privileges granted`, + { + security: { + authz: { + requiredPrivileges: [ReservedPrivilegesSet.operator, 'privilege1'], + }, + }, + kibanaPrivilegesResponse: { + privileges: { + kibana: [{ privilege: 'api:privilege1', authorized: false }], + }, + }, + kibanaCurrentUserResponse: { operator: false }, + esXpackSecurityUsageResponse: { operator_privileges: { enabled: false, available: false } }, + asserts: { + forbidden: true, + }, + } + ); + + testSecurityConfig( + `protected route returns forbidden if user has operator privileges requested and user is not operator`, + { + security: { + authz: { + requiredPrivileges: [ReservedPrivilegesSet.operator], }, }, + esXpackSecurityUsageResponse: { operator_privileges: { enabled: true, available: true } }, + kibanaCurrentUserResponse: { operator: false }, asserts: { forbidden: true, }, diff --git a/x-pack/plugins/security/server/authorization/api_authorization.ts b/x-pack/plugins/security/server/authorization/api_authorization.ts index 888d74e7d7bb2..dbfc8d03000e4 100644 --- a/x-pack/plugins/security/server/authorization/api_authorization.ts +++ b/x-pack/plugins/security/server/authorization/api_authorization.ts @@ -5,17 +5,22 @@ * 2.0. */ +import { ReservedPrivilegesSet } from '@kbn/core/server'; import type { AuthzDisabled, AuthzEnabled, HttpServiceSetup, + KibanaRequest, Logger, Privilege, PrivilegeSet, RouteAuthz, } from '@kbn/core/server'; -import { ReservedPrivilegesSet } from '@kbn/core/server'; -import type { AuthorizationServiceSetup } from '@kbn/security-plugin-types-server'; +import type { AuthenticatedUser } from '@kbn/security-plugin-types-common'; +import type { + AuthorizationServiceSetup, + EsSecurityConfig, +} from '@kbn/security-plugin-types-server'; import type { RecursiveReadonly } from '@kbn/utility-types'; import { API_OPERATION_PREFIX, SUPERUSER_PRIVILEGES } from '../../common/constants'; @@ -28,6 +33,11 @@ const isReservedPrivilegeSet = (privilege: string): privilege is ReservedPrivile return Object.hasOwn(ReservedPrivilegesSet, privilege); }; +interface InitApiAuthorization extends AuthorizationServiceSetup { + getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; + getSecurityConfig: () => Promise; +} + export function initAPIAuthorization( http: HttpServiceSetup, { @@ -35,7 +45,9 @@ export function initAPIAuthorization( checkPrivilegesDynamicallyWithRequest, checkPrivilegesWithRequest, mode, - }: AuthorizationServiceSetup, + getCurrentUser, + getSecurityConfig, + }: InitApiAuthorization, logger: Logger ) { http.registerOnPostAuth(async (request, response, toolkit) => { @@ -52,8 +64,55 @@ export function initAPIAuthorization( } const authz = security.authz as AuthzEnabled; + const normalizeRequiredPrivileges = async ( + privileges: AuthzEnabled['requiredPrivileges'] + ) => { + const hasOperatorPrivileges = privileges.some( + (privilege) => + privilege === ReservedPrivilegesSet.operator || + (typeof privilege === 'object' && + privilege.allRequired?.includes(ReservedPrivilegesSet.operator)) + ); + + // nothing to normalize + if (!hasOperatorPrivileges) { + return privileges; + } + + const securityConfig = await getSecurityConfig(); - const { requestedPrivileges, requestedReservedPrivileges } = authz.requiredPrivileges.reduce( + // nothing to normalize + if (securityConfig.operator_privileges.enabled) { + return privileges; + } + + return privileges.reduce((acc, privilege) => { + if (typeof privilege === 'object') { + const operatorPrivilegeIndex = + privilege.allRequired?.findIndex((p) => p === ReservedPrivilegesSet.operator) ?? -1; + + acc.push( + operatorPrivilegeIndex !== -1 + ? { + ...privilege, + // @ts-ignore wrong types for `toSpliced` + allRequired: privilege.allRequired?.toSpliced(operatorPrivilegeIndex, 1), + } + : privilege + ); + } else if (privilege !== ReservedPrivilegesSet.operator) { + acc.push(privilege); + } + + return acc; + }, []); + }; + + // We need to normalize privileges to drop unintended privilege checks. + // Operator privileges check should be only performed if the `operator_privileges` are enabled in config. + const requiredPrivileges = await normalizeRequiredPrivileges(authz.requiredPrivileges); + + const { requestedPrivileges, requestedReservedPrivileges } = requiredPrivileges.reduce( (acc, privilegeEntry) => { const privileges = typeof privilegeEntry === 'object' @@ -97,10 +156,15 @@ export function initAPIAuthorization( const checkSuperuserPrivilegesResponse = await checkPrivilegesWithRequest( request ).globally(SUPERUSER_PRIVILEGES); - kibanaPrivileges[ReservedPrivilegesSet.superuser] = checkSuperuserPrivilegesResponse.hasAllRequested; } + + if (reservedPrivilege === ReservedPrivilegesSet.operator) { + const currentUser = getCurrentUser(request); + + kibanaPrivileges[ReservedPrivilegesSet.operator] = currentUser?.operator ?? false; + } } const hasRequestedPrivilege = (kbPrivilege: Privilege | PrivilegeSet) => { @@ -118,8 +182,8 @@ export function initAPIAuthorization( return kibanaPrivileges[kbPrivilege]; }; - for (const requiredPrivilege of authz.requiredPrivileges) { - if (!hasRequestedPrivilege(requiredPrivilege)) { + for (const privilege of requiredPrivileges) { + if (!hasRequestedPrivilege(privilege)) { const missingPrivileges = Object.keys(kibanaPrivileges).filter( (key) => !kibanaPrivileges[key] ); diff --git a/x-pack/plugins/security/server/authorization/authorization_service.test.ts b/x-pack/plugins/security/server/authorization/authorization_service.test.ts index de3646166d8f9..3317fff03bd64 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.test.ts +++ b/x-pack/plugins/security/server/authorization/authorization_service.test.ts @@ -15,6 +15,7 @@ import { mockRegisterPrivilegesWithCluster, } from './service.test.mocks'; +import type { Client } from '@elastic/elasticsearch'; import { Subject } from 'rxjs'; import { coreMock, elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; @@ -38,6 +39,9 @@ const mockCheckPrivilegesDynamicallyWithRequest = Symbol(); const mockCheckSavedObjectsPrivilegesWithRequest = Symbol(); const mockPrivilegesService = Symbol(); const mockAuthorizationMode = Symbol(); +const mockEsSecurityResponse = { + security: { operator_privileges: { enabled: false, available: false } }, +}; beforeEach(() => { mockCheckPrivilegesFactory.mockReturnValue({ checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, @@ -59,6 +63,9 @@ afterEach(() => { it(`#setup returns exposed services`, () => { const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.asInternalUser.xpack.usage.mockResolvedValue( + mockEsSecurityResponse as Awaited> + ); const mockGetSpacesService = jest .fn() .mockReturnValue({ getSpaceId: jest.fn(), namespaceToSpaceId: jest.fn() }); @@ -126,6 +133,9 @@ describe('#start', () => { statusSubject = new Subject(); const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.asInternalUser.xpack.usage.mockResolvedValue( + mockEsSecurityResponse as Awaited> + ); const mockCoreSetup = coreMock.createSetup(); const authorizationService = new AuthorizationService(); @@ -194,6 +204,9 @@ describe('#start', () => { it('#stop unsubscribes from license and ES updates.', async () => { const mockClusterClient = elasticsearchServiceMock.createClusterClient(); + mockClusterClient.asInternalUser.xpack.usage.mockResolvedValue( + mockEsSecurityResponse as Awaited> + ); const statusSubject = new Subject(); const mockCoreSetup = coreMock.createSetup(); diff --git a/x-pack/plugins/security/server/authorization/authorization_service.tsx b/x-pack/plugins/security/server/authorization/authorization_service.tsx index c8e036b07679c..d58e24d3b84f6 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.tsx +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -123,6 +123,14 @@ export class AuthorizationService { this.applicationName ); + const esSecurityConfig = getClusterClient() + .then((client) => + client.asInternalUser.xpack.usage({ + filter_path: 'security.operator_privileges', + }) + ) + .then(({ security }) => security); + const authz = { actions, applicationName: this.applicationName, @@ -168,7 +176,11 @@ export class AuthorizationService { } ); - initAPIAuthorization(http, authz, loggers.get('api-authorization')); + initAPIAuthorization( + http, + { ...authz, getCurrentUser, getSecurityConfig: () => esSecurityConfig }, + loggers.get('api-authorization') + ); initAppAuthorization(http, authz, loggers.get('app-authorization'), features); http.registerOnPreResponse(async (request, preResponse, toolkit) => { diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 4b9479f51a0f3..958e22a3f2dd9 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { Client } from '@elastic/elasticsearch'; import { of } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; @@ -46,6 +47,13 @@ describe('Security Plugin', () => { mockCoreSetup = coreMock.createSetup({ pluginStartContract: { userProfiles: userProfileServiceMock.createStart() }, }); + + const core = coreMock.createRequestHandlerContext(); + + core.elasticsearch.client.asInternalUser.xpack.usage.mockResolvedValue({ + security: { operator_privileges: { enabled: false, available: false } }, + } as Awaited>); + mockCoreSetup.http.getServerInfo.mockReturnValue({ hostname: 'localhost', name: 'kibana', @@ -64,6 +72,12 @@ describe('Security Plugin', () => { mockCoreStart = coreMock.createStart(); + mockCoreSetup.getStartServices.mockResolvedValue([ + // @ts-expect-error only mocking the client we use + { elasticsearch: core.elasticsearch }, + mockSetupDependencies.features, + ]); + mockStartDependencies = { features: featuresPluginMock.createStart(), licensing: licensingMock.createStart(), diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index afd21a83712ae..119dbcef13427 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -80,7 +80,7 @@ export interface SecurityPluginSetup extends SecurityPluginSetupWithoutDeprecate /** * @deprecated Use `authz` methods from the `SecurityServiceStart` contract instead. */ - authz: AuthorizationServiceSetup; + authz: Omit; } export interface PluginSetupDependencies { @@ -344,7 +344,7 @@ export class SecurityPlugin return Object.freeze({ audit: this.auditSetup, authc: { - getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request), + getCurrentUser, }, authz: { actions: this.authorizationSetup.actions,