diff --git a/x-pack/plugins/security/common/model/deprecations.ts b/x-pack/plugins/security/common/model/deprecations.ts new file mode 100644 index 0000000000000..e990f370c5173 --- /dev/null +++ b/x-pack/plugins/security/common/model/deprecations.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { DeprecationsDetails, GetDeprecationsContext } from '../../../../../src/core/server'; +import type { Role } from './role'; + +export interface PrivilegeDeprecationsRolesByFeatureIdResponse { + roles?: Role[]; + errors?: DeprecationsDetails[]; +} + +export interface PrivilegeDeprecationsRolesByFeatureIdRequest { + context: GetDeprecationsContext; + featureId: string; +} +export interface PrivilegeDeprecationsService { + getKibanaRolesByFeatureId: ( + args: PrivilegeDeprecationsRolesByFeatureIdRequest + ) => Promise; +} diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index 8eb341ef9bd37..082e6bdc12cd0 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -33,3 +33,8 @@ export { RoleTemplate, RoleMapping, } from './role_mapping'; +export { + PrivilegeDeprecationsRolesByFeatureIdRequest, + PrivilegeDeprecationsRolesByFeatureIdResponse, + PrivilegeDeprecationsService, +} from './deprecations'; diff --git a/x-pack/plugins/security/server/authorization/index.ts b/x-pack/plugins/security/server/authorization/index.ts index 4d67f3435e7da..221baa85a65f6 100644 --- a/x-pack/plugins/security/server/authorization/index.ts +++ b/x-pack/plugins/security/server/authorization/index.ts @@ -13,3 +13,4 @@ export { } from './authorization_service'; export { CheckSavedObjectsPrivileges } from './check_saved_objects_privileges'; export { CheckPrivilegesPayload } from './types'; +export { transformElasticsearchRoleToRole, ElasticsearchRole } from './roles'; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts similarity index 96% rename from x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts rename to x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts index fa119ca704753..c0dab16f97af8 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts @@ -8,10 +8,10 @@ import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD, -} from '../../../../../common/constants'; -import type { Role, RoleKibanaPrivilege } from '../../../../../common/model'; -import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer'; -import { ResourceSerializer } from '../../../../authorization/resource_serializer'; +} from '../../../common/constants'; +import type { Role, RoleKibanaPrivilege } from '../../../common/model'; +import { PrivilegeSerializer } from '../privilege_serializer'; +import { ResourceSerializer } from '../resource_serializer'; export type ElasticsearchRole = Pick & { applications: Array<{ diff --git a/x-pack/plugins/security/server/authorization/roles/index.ts b/x-pack/plugins/security/server/authorization/roles/index.ts new file mode 100644 index 0000000000000..a5047a1872c09 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/roles/index.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformElasticsearchRoleToRole, ElasticsearchRole } from './elasticsearch_role'; diff --git a/x-pack/plugins/security/server/deprecations/index.ts b/x-pack/plugins/security/server/deprecations/index.ts new file mode 100644 index 0000000000000..05802a5a673c5 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/index.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +/** + * getKibanaRolesByFeature + */ + +export { getPrivilegeDeprecationsService } from './privilege_deprecations'; diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts new file mode 100644 index 0000000000000..e889eb17d5af9 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts @@ -0,0 +1,284 @@ +/* + * 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 { GetDeprecationsContext } from 'src/core/server'; +import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks'; + +import { getPrivilegeDeprecationsService } from '.'; +import { licenseMock } from '../../common/licensing/index.mock'; + +const kibanaIndexName = '.a-kibana-index'; +const application = `kibana-${kibanaIndexName}`; + +describe('#getPrivilegeDeprecationsService', () => { + describe('#getKibanaRolesByFeatureId', () => { + const mockAsCurrentUser = elasticsearchServiceMock.createScopedClusterClient(); + const mockLicense = licenseMock.create(); + const mockLogger = loggingSystemMock.createLogger(); + const authz = { applicationName: application }; + + const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService( + authz, + mockLicense, + mockLogger + ); + + it('happy path to find siem roles with feature_siem privileges', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['feature_siem.all', 'feature_siem.cases_read'], + resources: ['space:securitySolutions'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "roles": Array [ + Object { + "_transform_error": Array [], + "_unrecognized_applications": Array [], + "elasticsearch": Object { + "cluster": Array [], + "indices": Array [], + "run_as": Array [], + }, + "kibana": Array [ + Object { + "base": Array [], + "feature": Object { + "siem": Array [ + "all", + "cases_read", + ], + }, + "spaces": Array [ + "securitySolutions", + ], + }, + ], + "metadata": Object { + "_reserved": true, + }, + "name": "first_role", + "transient_metadata": Object { + "enabled": true, + }, + }, + ], + } + `); + }); + + it('happy path to find siem roles with feature_siem and feature_foo and feature_bar privileges', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + 'feature_siem.all', + 'feature_siem.cases_read', + ], + resources: ['space:securitySolutions'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "roles": Array [ + Object { + "_transform_error": Array [], + "_unrecognized_applications": Array [], + "elasticsearch": Object { + "cluster": Array [], + "indices": Array [], + "run_as": Array [], + }, + "kibana": Array [ + Object { + "base": Array [], + "feature": Object { + "bar": Array [ + "bar-privilege-1", + ], + "foo": Array [ + "foo-privilege-1", + "foo-privilege-2", + ], + "siem": Array [ + "all", + "cases_read", + ], + }, + "spaces": Array [ + "securitySolutions", + ], + }, + ], + "metadata": Object { + "_reserved": true, + }, + "name": "first_role", + "transient_metadata": Object { + "enabled": true, + }, + }, + ], + } + `); + }); + + it('happy path to NOT find siem roles with and feature_foo and feature_bar privileges', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createSuccessTransportRequestPromise({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: [ + 'feature_foo.foo-privilege-1', + 'feature_foo.foo-privilege-2', + 'feature_bar.bar-privilege-1', + ], + resources: ['space:securitySolutions'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "roles": Array [], + } + `); + }); + + it('unhappy path with status code 400, we should have the attribute errors', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createErrorTransportRequestPromise({ + message: 'Test error', + statusCode: 400, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "A user with the \\"manage_security\\" cluster privilege is required to perform this check.", + ], + }, + "level": "fetch_error", + "message": "Error retrieving roles for privilege deprecations: Test error", + "title": "Error in privilege deprecations services", + }, + ], + } + `); + }); + + it('unhappy path with status code 403, we should have unauthorized message in the attribute errors', async () => { + mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( + elasticsearchServiceMock.createErrorTransportRequestPromise({ + message: 'Test error', + statusCode: 403, + }) + ); + + const mockContext = { + esClient: mockAsCurrentUser, + savedObjectsClient: jest.fn(), + } as unknown as GetDeprecationsContext; + + const resp = await getKibanaRolesByFeatureId({ context: mockContext, featureId: 'siem' }); + expect(resp).toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "A user with the \\"manage_security\\" cluster privilege is required to perform this check.", + ], + }, + "level": "fetch_error", + "message": "You must have the 'manage_security' cluster privilege to fix role deprecations.", + "title": "Error in privilege deprecations services", + }, + ], + } + `); + }); + }); +}); diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts new file mode 100644 index 0000000000000..df212d5c7bde3 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts @@ -0,0 +1,106 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { Logger } from 'src/core/server'; + +import type { SecurityLicense } from '../../common/licensing'; +import type { + PrivilegeDeprecationsRolesByFeatureIdRequest, + PrivilegeDeprecationsRolesByFeatureIdResponse, +} from '../../common/model'; +import { transformElasticsearchRoleToRole } from '../authorization'; +import type { AuthorizationServiceSetupInternal, ElasticsearchRole } from '../authorization'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; + +export const getPrivilegeDeprecationsService = ( + authz: Pick, + license: SecurityLicense, + logger: Logger +) => { + const getKibanaRolesByFeatureId = async ({ + context, + featureId, + }: PrivilegeDeprecationsRolesByFeatureIdRequest): Promise => { + // Nothing to do if security is disabled + if (!license.isEnabled()) { + return { + roles: [], + }; + } + let kibanaRoles; + try { + const { body: elasticsearchRoles } = await context.esClient.asCurrentUser.security.getRole< + Record + >(); + kibanaRoles = Object.entries(elasticsearchRoles).map(([roleName, elasticsearchRole]) => + transformElasticsearchRoleToRole( + // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` + elasticsearchRole, + roleName, + authz.applicationName + ) + ); + } catch (e) { + const statusCode = getErrorStatusCode(e); + const isUnauthorized = statusCode === 403; + const message = isUnauthorized + ? i18n.translate('xpack.security.privilegeDeprecationsService.error.unauthorized.message', { + defaultMessage: `You must have the 'manage_security' cluster privilege to fix role deprecations.`, + }) + : i18n.translate( + 'xpack.security.privilegeDeprecationsService.error.retrievingRoles.message', + { + defaultMessage: `Error retrieving roles for privilege deprecations: {message}`, + values: { + message: getDetailedErrorMessage(e), + }, + } + ); + + if (isUnauthorized) { + logger.warn( + `Failed to retrieve roles when checking for deprecations: the manage_security cluster privilege is required` + ); + } else { + logger.error( + `Failed to retrieve roles when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + e + )}` + ); + } + + return { + errors: [ + { + title: i18n.translate('xpack.security.privilegeDeprecationsService.error.title', { + defaultMessage: `Error in privilege deprecations services`, + }), + level: 'fetch_error', + message, + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.security.privilegeDeprecationsService.manualSteps.message', { + defaultMessage: + 'A user with the "manage_security" cluster privilege is required to perform this check.', + }), + ], + }, + }, + ], + }; + } + return { + roles: kibanaRoles.filter((role) => + role.kibana.find((privilege) => Object.hasOwnProperty.call(privilege.feature, featureId)) + ), + }; + }; + return Object.freeze({ + getKibanaRolesByFeatureId, + }); +}; diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index f1f858a40a465..7cae0d29bf943 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -28,6 +28,9 @@ function createSetupMock() { }, registerSpacesService: jest.fn(), license: licenseMock.create(), + privilegeDeprecationsService: { + getKibanaRolesByFeatureId: jest.fn(), + }, }; } diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index eb88aba1c0e1b..4784e14a11fb4 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -123,6 +123,9 @@ describe('Security Plugin', () => { "isEnabled": [Function], "isLicenseAvailable": [Function], }, + "privilegeDeprecationsService": Object { + "getKibanaRolesByFeatureId": [Function], + }, } `); }); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index e3da0716f29ee..2ad75a1c53174 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -30,7 +30,7 @@ import type { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; import type { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; import type { SecurityLicense } from '../common/licensing'; import { SecurityLicenseService } from '../common/licensing'; -import type { AuthenticatedUser } from '../common/model'; +import type { AuthenticatedUser, PrivilegeDeprecationsService } from '../common/model'; import type { AnonymousAccessServiceStart } from './anonymous_access'; import { AnonymousAccessService } from './anonymous_access'; import type { AuditServiceSetup } from './audit'; @@ -44,6 +44,7 @@ import type { AuthorizationServiceSetup, AuthorizationServiceSetupInternal } fro import { AuthorizationService } from './authorization'; import type { ConfigSchema, ConfigType } from './config'; import { createConfig } from './config'; +import { getPrivilegeDeprecationsService } from './deprecations'; import { ElasticsearchService } from './elasticsearch'; import type { SecurityFeatureUsageServiceStart } from './feature_usage'; import { SecurityFeatureUsageService } from './feature_usage'; @@ -85,6 +86,10 @@ export interface SecurityPluginSetup { * Exposes services for audit logging. */ audit: AuditServiceSetup; + /** + * Exposes services to access kibana roles per feature id with the GetDeprecationsContext + */ + privilegeDeprecationsService: PrivilegeDeprecationsService; } /** @@ -321,9 +326,7 @@ export class SecurityPlugin asScoped: this.auditSetup.asScoped, getLogger: this.auditSetup.getLogger, }, - authc: { getCurrentUser: (request) => this.getAuthentication().getCurrentUser(request) }, - authz: { actions: this.authorizationSetup.actions, checkPrivilegesWithRequest: this.authorizationSetup.checkPrivilegesWithRequest, @@ -333,8 +336,12 @@ export class SecurityPlugin this.authorizationSetup.checkSavedObjectsPrivilegesWithRequest, mode: this.authorizationSetup.mode, }, - license, + privilegeDeprecationsService: getPrivilegeDeprecationsService( + this.authorizationSetup, + license, + this.logger.get('deprecations') + ), }); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts index 8334dd3c05476..e090cd26dc39f 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts @@ -5,5 +5,5 @@ * 2.0. */ -export { ElasticsearchRole, transformElasticsearchRoleToRole } from './elasticsearch_role'; +export { ElasticsearchRole, transformElasticsearchRoleToRole } from '../../../../authorization'; export { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './put_payload'; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts index 8a560d7b6dd87..7fb2baf0fd410 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts @@ -10,10 +10,10 @@ import _ from 'lodash'; import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; +import type { ElasticsearchRole } from '.'; import { GLOBAL_RESOURCE } from '../../../../../common/constants'; import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer'; import { ResourceSerializer } from '../../../../authorization/resource_serializer'; -import type { ElasticsearchRole } from './elasticsearch_role'; /** * Elasticsearch specific portion of the role definition.