[8.18] [SecuritySolution] Fix Entity Store init API doesn't check for indices privileges (#215329) (#215866)

# Backport

This will backport the following commits from `main` to `8.18`:
- [[SecuritySolution] Fix Entity Store init API doesn't check for
indices privileges
(#215329)](https://github.com/elastic/kibana/pull/215329)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Pablo
Machado","email":"pablo.nevesmachado@elastic.co"},"sourceCommit":{"committedDate":"2025-03-25T10:48:20Z","message":"[SecuritySolution]
Fix Entity Store init API doesn't check for indices privileges
(#215329)\n\n## Summary\n\n* Add privileges check to the entity store
init API\n* Refactor privileges check code to be reusable\n* Move
privilege check code to the entity store API client\n\n### How to test
it?\n* Create a new instance with security solution data\n* Create a new
user with all cluster and kibana credentials but no
index\nprivileges.\n* Login with the unprivileged and call the init
API\n* It should return a long error msg with all required index
patterns.\n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios","sha":"ef7fe99f42c513c36001a254e6b8120e043a1d2e","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["bug","release_note:fix","v9.0.0","Team:
SecuritySolution","Theme: entity_analytics","Feature:Entity
Analytics","Team:Entity
Analytics","backport:version","v8.18.0","v9.1.0","v8.19.0"],"title":"[SecuritySolution]
Fix Entity Store init API doesn't check for indices
privileges","number":215329,"url":"https://github.com/elastic/kibana/pull/215329","mergeCommit":{"message":"[SecuritySolution]
Fix Entity Store init API doesn't check for indices privileges
(#215329)\n\n## Summary\n\n* Add privileges check to the entity store
init API\n* Refactor privileges check code to be reusable\n* Move
privilege check code to the entity store API client\n\n### How to test
it?\n* Create a new instance with security solution data\n* Create a new
user with all cluster and kibana credentials but no
index\nprivileges.\n* Login with the unprivileged and call the init
API\n* It should return a long error msg with all required index
patterns.\n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios","sha":"ef7fe99f42c513c36001a254e6b8120e043a1d2e"}},"sourceBranch":"main","suggestedTargetBranches":["9.0","8.18","8.x"],"targetPullRequestStates":[{"branch":"9.0","label":"v9.0.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.18","label":"v8.18.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/215329","number":215329,"mergeCommit":{"message":"[SecuritySolution]
Fix Entity Store init API doesn't check for indices privileges
(#215329)\n\n## Summary\n\n* Add privileges check to the entity store
init API\n* Refactor privileges check code to be reusable\n* Move
privilege check code to the entity store API client\n\n### How to test
it?\n* Create a new instance with security solution data\n* Create a new
user with all cluster and kibana credentials but no
index\nprivileges.\n* Login with the unprivileged and call the init
API\n* It should return a long error msg with all required index
patterns.\n\n### Checklist\n\n- [x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common
scenarios","sha":"ef7fe99f42c513c36001a254e6b8120e043a1d2e"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Pablo Machado 2025-03-26 09:34:56 +01:00 committed by GitHub
parent 3ae0cc7fe0
commit 55b5c7b057
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 290 additions and 169 deletions

View file

@ -5,97 +5,169 @@
* 2.0.
*/
import { getAllMissingPrivileges } from './privileges';
import type { EntityAnalyticsPrivileges } from '../api/entity_analytics';
import { getMissingPrivilegesErrorMessage, getAllMissingPrivileges } from './privileges';
import type { MissingPrivileges } from './privileges';
describe('getAllMissingPrivileges', () => {
it('should return all missing privileges for elasticsearch and kibana', () => {
const privileges: EntityAnalyticsPrivileges = {
privileges: {
describe('privileges', () => {
describe('getAllMissingPrivileges', () => {
it('should return all missing privileges for elasticsearch and kibana', () => {
const privileges: EntityAnalyticsPrivileges = {
privileges: {
elasticsearch: {
index: {
'logs-*': { read: true, view_index_metadata: true },
'auditbeat-*': { read: false, view_index_metadata: false },
},
cluster: {
manage_enrich: false,
manage_ingest_pipelines: true,
},
},
kibana: {
'saved_object:entity-engine-status/all': false,
'saved_object:entity-definition/all': true,
},
},
has_all_required: false,
has_read_permissions: false,
has_write_permissions: false,
};
const result = getAllMissingPrivileges(privileges);
expect(result).toEqual({
elasticsearch: {
index: {
'logs-*': { read: true, view_index_metadata: true },
'auditbeat-*': { read: false, view_index_metadata: false },
index: [{ indexName: 'auditbeat-*', privileges: ['read', 'view_index_metadata'] }],
cluster: ['manage_enrich'],
},
kibana: ['saved_object:entity-engine-status/all'],
});
});
it('should return empty lists if all privileges are true', () => {
const privileges: EntityAnalyticsPrivileges = {
privileges: {
elasticsearch: {
index: {
'logs-*': { read: true, view_index_metadata: true },
},
cluster: {
manage_enrich: true,
},
},
cluster: {
manage_enrich: false,
manage_ingest_pipelines: true,
kibana: {
'saved_object:entity-engine-status/all': true,
},
},
kibana: {
'saved_object:entity-engine-status/all': false,
'saved_object:entity-definition/all': true,
has_all_required: true,
has_read_permissions: true,
has_write_permissions: true,
};
const result = getAllMissingPrivileges(privileges);
expect(result).toEqual({
elasticsearch: {
index: [],
cluster: [],
},
},
has_all_required: false,
has_read_permissions: false,
has_write_permissions: false,
};
kibana: [],
});
});
const result = getAllMissingPrivileges(privileges);
it('should handle empty privileges object', () => {
const privileges: EntityAnalyticsPrivileges = {
privileges: {
elasticsearch: {
index: {},
cluster: {},
},
kibana: {},
},
has_all_required: false,
has_read_permissions: false,
has_write_permissions: false,
};
expect(result).toEqual({
elasticsearch: {
index: [{ indexName: 'auditbeat-*', privileges: ['read', 'view_index_metadata'] }],
cluster: ['manage_enrich'],
},
kibana: ['saved_object:entity-engine-status/all'],
const result = getAllMissingPrivileges(privileges);
expect(result).toEqual({
elasticsearch: {
index: [],
cluster: [],
},
kibana: [],
});
});
});
it('should return empty lists if all privileges are true', () => {
const privileges: EntityAnalyticsPrivileges = {
privileges: {
describe('getMissingPrivilegesErrorMessage', () => {
it('should return error messages for missing index, cluster, and kibana privileges', () => {
const missingPrivileges: MissingPrivileges = {
elasticsearch: {
index: {
'logs-*': { read: true, view_index_metadata: true },
},
cluster: {
manage_enrich: true,
},
index: [
{ indexName: 'auditbeat-*', privileges: ['read', 'view_index_metadata'] },
{ indexName: 'logs-*', privileges: ['write'] },
],
cluster: ['manage_enrich', 'monitor'],
},
kibana: {
'saved_object:entity-engine-status/all': true,
},
},
has_all_required: true,
has_read_permissions: true,
has_write_permissions: true,
};
kibana: ['saved_object:entity-engine-status/all', 'saved_object:entity-definition/all'],
};
const result = getAllMissingPrivileges(privileges);
const result = getMissingPrivilegesErrorMessage(missingPrivileges);
expect(result).toEqual({
elasticsearch: {
index: [],
cluster: [],
},
kibana: [],
expect(result).toBe(
`Missing [read, view_index_metadata] privileges for index 'auditbeat-*'.\n` +
`Missing [write] privileges for index 'logs-*'.\n` +
`Missing [manage_enrich, monitor] cluster privileges.\n` +
`Missing [saved_object:entity-engine-status/all, saved_object:entity-definition/all] Kibana privileges.`
);
});
});
it('should handle empty privileges object', () => {
const privileges: EntityAnalyticsPrivileges = {
privileges: {
it('should return error messages for missing index and cluster privileges only', () => {
const missingPrivileges: MissingPrivileges = {
elasticsearch: {
index: {},
cluster: {},
index: [{ indexName: 'auditbeat-*', privileges: ['read'] }],
cluster: ['manage_enrich'],
},
kibana: {},
},
has_all_required: false,
has_read_permissions: false,
has_write_permissions: false,
};
kibana: [],
};
const result = getAllMissingPrivileges(privileges);
const result = getMissingPrivilegesErrorMessage(missingPrivileges);
expect(result).toEqual({
elasticsearch: {
index: [],
cluster: [],
},
kibana: [],
expect(result).toBe(
`Missing [read] privileges for index 'auditbeat-*'.\n` +
`Missing [manage_enrich] cluster privileges.`
);
});
it('should return error messages for missing kibana privileges only', () => {
const missingPrivileges: MissingPrivileges = {
elasticsearch: {
index: [],
cluster: [],
},
kibana: ['saved_object:entity-engine-status/all'],
};
const result = getMissingPrivilegesErrorMessage(missingPrivileges);
expect(result).toBe(`Missing [saved_object:entity-engine-status/all] Kibana privileges.`);
});
it('should return an empty string if there are no missing privileges', () => {
const missingPrivileges: MissingPrivileges = {
elasticsearch: {
index: [],
cluster: [],
},
kibana: [],
};
const result = getMissingPrivilegesErrorMessage(missingPrivileges);
expect(result).toBe('');
});
});
});

View file

@ -7,7 +7,17 @@
import type { EntityAnalyticsPrivileges } from '../api/entity_analytics';
export const getAllMissingPrivileges = (privilege: EntityAnalyticsPrivileges) => {
export interface MissingPrivileges {
elasticsearch: {
index: Array<{ indexName: string; privileges: string[] }>;
cluster: string[];
};
kibana: string[];
}
export const getAllMissingPrivileges = (
privilege: EntityAnalyticsPrivileges
): MissingPrivileges => {
const esPrivileges = privilege.privileges.elasticsearch;
const kbnPrivileges = privilege.privileges.kibana;
@ -24,6 +34,20 @@ export const getAllMissingPrivileges = (privilege: EntityAnalyticsPrivileges) =>
};
};
export const getMissingPrivilegesErrorMessage = ({ elasticsearch, kibana }: MissingPrivileges) =>
[
...elasticsearch.index.map(
({ indexName, privileges }) =>
`Missing [${privileges.join(', ')}] privileges for index '${indexName}'.`
),
...(elasticsearch.cluster.length > 0
? [`Missing [${elasticsearch.cluster.join(', ')}] cluster privileges.`]
: []),
...(kibana.length > 0 ? [`Missing [${kibana.join(', ')}] Kibana privileges.`] : []),
].join('\n');
const filterUnauthorized = (obj: Record<string, boolean> | undefined) =>
Object.entries(obj ?? {})
.filter(([_, authorized]) => !authorized)

View file

@ -25,7 +25,17 @@ import moment from 'moment';
import type { EntityDefinitionWithState } from '@kbn/entityManager-plugin/server/lib/entities/types';
import type { EntityDefinition } from '@kbn/entities-schema';
import type { estypes } from '@elastic/elasticsearch';
import { getAllMissingPrivileges } from '../../../../common/entity_analytics/privileges';
import { SO_ENTITY_DEFINITION_TYPE } from '@kbn/entityManager-plugin/server/saved_objects';
import { RISK_SCORE_INDEX_PATTERN } from '../../../../common/constants';
import {
ENTITY_STORE_INDEX_PATTERN,
ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES,
ENTITY_STORE_SOURCE_REQUIRED_ES_INDEX_PRIVILEGES,
} from '../../../../common/entity_analytics/entity_store/constants';
import {
getAllMissingPrivileges,
getMissingPrivilegesErrorMessage,
} from '../../../../common/entity_analytics/privileges';
import { merge } from '../../../../common/utils/objects/merge';
import { getEnabledStoreEntityTypes } from '../../../../common/entity_analytics/entity_store/utils';
import { EntityType } from '../../../../common/entity_analytics/types';
@ -101,9 +111,9 @@ import {
import { CRITICALITY_VALUES } from '../asset_criticality/constants';
import { createEngineDescription } from './installation/engine_description';
import { convertToEntityManagerDefinition } from './entity_definitions/entity_manager_conversion';
import { getEntityStoreSourceIndicesPrivileges } from './utils/get_entity_store_privileges';
import type { ApiKeyManager } from './auth/api_key';
import { checkAndFormatPrivileges } from '../utils/check_and_format_privileges';
import { entityEngineDescriptorTypeName } from './saved_object';
// Workaround. TransformState type is wrong. The health type should be: TransformHealth from '@kbn/transform-plugin/common/types/transform_stats'
export interface TransformHealth extends estypes.TransformGetTransformStatsTransformStatsHealth {
@ -839,22 +849,15 @@ export class EntityStoreDataClient {
);
}
const privileges = await getEntityStoreSourceIndicesPrivileges(
this.options.request,
this.options.security,
indexPatterns
);
const privileges = await this.getEntityStoreSourceIndicesPrivileges(indexPatterns);
if (!privileges.has_all_required) {
const missingPrivilegesMsg = getAllMissingPrivileges(privileges).elasticsearch.index.map(
({ indexName, privileges: missingPrivileges }) =>
`Missing [${missingPrivileges.join(', ')}] privileges for index '${indexName}'.`
const missingPrivilegesMsg = getMissingPrivilegesErrorMessage(
getAllMissingPrivileges(privileges)
);
throw new Error(
`The current user does not have the required indices privileges for updating the '${
engine.type
}' entity store.\n${missingPrivilegesMsg.join('\n')}`
`The current user does not have the required indices privileges for updating the '${engine.type}' entity store.\n${missingPrivilegesMsg}`
);
}
@ -900,6 +903,61 @@ export class EntityStoreDataClient {
};
}
/**
* Get the index privileges required for installing all entity store resources
*/
public getEntityStoreInitPrivileges = async (indices: string[]) => {
const security = this.options.security;
// The entity store needs access to all security solution indices
const indicesPrivileges = this.getEntityStoreSourceRequiredIndicesPrivileges(indices);
// The entity store has to create the following indices
indicesPrivileges[ENTITY_STORE_INDEX_PATTERN] = ['read', 'manage'];
indicesPrivileges[RISK_SCORE_INDEX_PATTERN] = ['read', 'manage'];
return checkAndFormatPrivileges({
request: this.options.request,
security,
privilegesToCheck: {
kibana: [
security.authz.actions.savedObject.get(entityEngineDescriptorTypeName, 'create'),
security.authz.actions.savedObject.get(SO_ENTITY_DEFINITION_TYPE, 'create'),
],
elasticsearch: {
cluster: ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES,
index: indicesPrivileges,
},
},
});
};
/**
* Get the index privileges required for running the transform
*/
public getEntityStoreSourceIndicesPrivileges = (indexPatterns: string[]) => {
const requiredIndicesPrivileges =
this.getEntityStoreSourceRequiredIndicesPrivileges(indexPatterns);
return checkAndFormatPrivileges({
request: this.options.request,
security: this.options.security,
privilegesToCheck: {
elasticsearch: {
cluster: [],
index: requiredIndicesPrivileges,
},
},
});
};
private getEntityStoreSourceRequiredIndicesPrivileges(securitySolutionIndices: string[]) {
return securitySolutionIndices.reduce<Record<string, string[]>>((acc, index) => {
acc[index] = ENTITY_STORE_SOURCE_REQUIRED_ES_INDEX_PRIVILEGES;
return acc;
}, {});
}
private log(
level: Exclude<keyof Logger, 'get' | 'log' | 'isLevelEnabled'>,
entityType: EntityType,

View file

@ -10,6 +10,10 @@ import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils';
import { transformError } from '@kbn/securitysolution-es-utils';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
getAllMissingPrivileges,
getMissingPrivilegesErrorMessage,
} from '../../../../../common/entity_analytics/privileges';
import { EntityType } from '../../../../../common/search_strategy';
import type { InitEntityEngineResponse } from '../../../../../common/api/entity_analytics/entity_store/engine/init.gen';
import {
@ -20,6 +24,7 @@ import { API_VERSIONS, APP_ID } from '../../../../../common/constants';
import type { EntityAnalyticsRoutesDeps } from '../../types';
import { checkAndInitAssetCriticalityResources } from '../../asset_criticality/check_and_init_asset_criticality_resources';
import { buildInitRequestBodyValidation } from './validation';
import { buildIndexPatterns } from '../utils';
export const initEntityEngineRoute = (
router: EntityAnalyticsRoutesDeps['router'],
@ -51,15 +56,40 @@ export const initEntityEngineRoute = (
const siemResponse = buildSiemResponse(response);
const secSol = await context.securitySolution;
const { pipelineDebugMode } = config.entityAnalytics.entityStore.developer;
await checkAndInitAssetCriticalityResources(context, logger);
const { getSpaceId, getAppClient, getDataViewsService } = await context.securitySolution;
const entityStoreClient = secSol.getEntityStoreDataClient();
try {
const body: InitEntityEngineResponse = await secSol
.getEntityStoreDataClient()
.init(EntityType[request.params.entityType], request.body, {
pipelineDebugMode,
const securitySolutionIndices = await buildIndexPatterns(
getSpaceId(),
getAppClient(),
getDataViewsService()
);
const privileges = await entityStoreClient.getEntityStoreInitPrivileges(
securitySolutionIndices
);
if (!privileges.has_all_required) {
const missingPrivilegesMsg = getMissingPrivilegesErrorMessage(
getAllMissingPrivileges(privileges)
);
return siemResponse.error({
statusCode: 403,
body: `User does not have the required privileges to initialize the entity engine\n${missingPrivilegesMsg}`,
});
}
await checkAndInitAssetCriticalityResources(context, logger);
const body: InitEntityEngineResponse = await entityStoreClient.init(
EntityType[request.params.entityType],
request.body,
{
pipelineDebugMode,
}
);
return response.ok({ body });
} catch (e) {

View file

@ -13,7 +13,6 @@ import { APP_ID, API_VERSIONS } from '../../../../../common/constants';
import type { EntityAnalyticsRoutesDeps } from '../../types';
import { AUDIT_CATEGORY, AUDIT_OUTCOME, AUDIT_TYPE } from '../../audit';
import { getEntityStorePrivileges } from '../utils/get_entity_store_privileges';
import { buildIndexPatterns } from '../utils';
export const entityStoreInternalPrivilegesRoute = (
@ -41,7 +40,6 @@ export const entityStoreInternalPrivilegesRoute = (
): Promise<IKibanaResponse<EntityStoreGetPrivilegesResponse>> => {
const siemResponse = buildSiemResponse(response);
try {
const [_, { security }] = await getStartServices();
const { getSpaceId, getAppClient, getDataViewsService } = await context.securitySolution;
const securitySolution = await context.securitySolution;
@ -60,7 +58,10 @@ export const entityStoreInternalPrivilegesRoute = (
getAppClient(),
getDataViewsService()
);
const body = await getEntityStorePrivileges(request, security, securitySolutionIndices);
const body = await securitySolution
.getEntityStoreDataClient()
.getEntityStoreInitPrivileges(securitySolutionIndices);
return response.ok({ body });
} catch (e) {

View file

@ -1,73 +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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { KibanaRequest } from '@kbn/core/server';
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
import { SO_ENTITY_DEFINITION_TYPE } from '@kbn/entityManager-plugin/server/saved_objects';
import { RISK_SCORE_INDEX_PATTERN } from '../../../../../common/constants';
import {
ENTITY_STORE_INDEX_PATTERN,
ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES,
ENTITY_STORE_SOURCE_REQUIRED_ES_INDEX_PRIVILEGES,
} from '../../../../../common/entity_analytics/entity_store/constants';
import { checkAndFormatPrivileges } from '../../utils/check_and_format_privileges';
import { entityEngineDescriptorTypeName } from '../saved_object';
export const getEntityStorePrivileges = (
request: KibanaRequest,
security: SecurityPluginStart,
securitySolutionIndices: string[]
) => {
// The entity store needs access to all security solution indices
const indicesPrivileges = getEntityStoreSourceRequiredIndicesPrivileges(securitySolutionIndices);
// The entity store has to create the following indices
indicesPrivileges[ENTITY_STORE_INDEX_PATTERN] = ['read', 'manage'];
indicesPrivileges[RISK_SCORE_INDEX_PATTERN] = ['read', 'manage'];
return checkAndFormatPrivileges({
request,
security,
privilegesToCheck: {
kibana: [
security.authz.actions.savedObject.get(entityEngineDescriptorTypeName, 'create'),
security.authz.actions.savedObject.get(SO_ENTITY_DEFINITION_TYPE, 'create'),
],
elasticsearch: {
cluster: ENTITY_STORE_REQUIRED_ES_CLUSTER_PRIVILEGES,
index: indicesPrivileges,
},
},
});
};
// Get the index privileges required for running the transform
export const getEntityStoreSourceIndicesPrivileges = (
request: KibanaRequest,
security: SecurityPluginStart,
indexPatterns: string[]
) => {
const requiredIndicesPrivileges = getEntityStoreSourceRequiredIndicesPrivileges(indexPatterns);
return checkAndFormatPrivileges({
request,
security,
privilegesToCheck: {
elasticsearch: {
cluster: [],
index: requiredIndicesPrivileges,
},
},
});
};
const getEntityStoreSourceRequiredIndicesPrivileges = (securitySolutionIndices: string[]) => {
return securitySolutionIndices.reduce<Record<string, string[]>>((acc, index) => {
acc[index] = ENTITY_STORE_SOURCE_REQUIRED_ES_INDEX_PRIVILEGES;
return acc;
}, {});
};

View file

@ -36,7 +36,7 @@ export const riskEngineInitRoute = (
{ version: '1', validate: {} },
withRiskEnginePrivilegeCheck(
getStartServices,
async (context, request, response): Promise<IKibanaResponse<InitRiskEngineResponse>> => {
async (context, _request, response): Promise<IKibanaResponse<InitRiskEngineResponse>> => {
const securitySolution = await context.securitySolution;
securitySolution.getAuditLogger()?.log({

View file

@ -55,8 +55,17 @@ export default ({ getService }: FtrProviderContext) => {
it('should return "error" when the security data view does not exist', async () => {
await dataView.delete('security-solution');
await utils.initEntityEngineForEntityType('host');
await utils.waitForEngineStatus('host', 'error');
const { body, status } = await api.initEntityEngine(
{
params: { entityType: 'host' },
body: {},
},
'default'
);
expect(status).toEqual(500);
expect(body.message).toContain('Data view not found');
});
});