From 51f75a5655224d77c97b944f8b603ff34ec92714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 26 Nov 2020 17:04:24 +0100 Subject: [PATCH] Added data streams privileges to better control delete actions in UI (#83573) * Added data streams privileges to better control delete actions in UI * Fix type check issues * Change data streams privileges request * Fixed type check issue * Fixed api integration test * Cleaned up not needed code * Renamed some data streams and added a default value for stats find Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/test_subjects.ts | 2 + .../home/data_streams_tab.helpers.ts | 12 + .../home/data_streams_tab.test.ts | 73 ++++++ .../common/lib/data_stream_serialization.ts | 2 + .../common/types/data_streams.ts | 16 +- .../data_stream_detail_panel.tsx | 2 +- .../data_stream_list/data_stream_list.tsx | 9 +- .../data_stream_table/data_stream_table.tsx | 6 +- .../server/client/elasticsearch.ts | 22 -- .../plugins/index_management/server/plugin.ts | 3 +- .../component_templates/privileges.test.ts | 2 + .../api/data_streams/register_get_route.ts | 221 ++++++++++++------ .../index_management/server/shared_imports.ts | 2 +- .../plugins/index_management/server/types.ts | 3 +- .../index_management/data_streams.ts | 9 + 15 files changed, 271 insertions(+), 113 deletions(-) diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index 04843cae6a57e..e8105ac2937c0 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -13,6 +13,8 @@ export type TestSubjects = | 'createTemplateButton' | 'dataStreamsEmptyPromptTemplateLink' | 'dataStreamTable' + | 'deleteDataStreamsButton' + | 'deleteDataStreamButton' | 'deleteSystemTemplateCallOut' | 'deleteTemplateButton' | 'deleteTemplatesConfirmation' diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index 4e0486e55720d..9c92af30097a2 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -24,6 +24,7 @@ export interface DataStreamsTabTestBed extends TestBed { clickNameAt: (index: number) => void; clickIndicesAt: (index: number) => void; clickDeleteActionAt: (index: number) => void; + selectDataStream: (name: string, selected: boolean) => void; clickConfirmDelete: () => void; clickDeleteDataStreamButton: () => void; clickDetailPanelIndexTemplateLink: () => void; @@ -125,6 +126,13 @@ export const setup = async (overridingDependencies: any = {}): Promise { + const { + form: { selectCheckBox }, + } = testBed; + selectCheckBox(`checkboxSelectRow-${name}`, selected); + }; + const findDeleteConfirmationModal = () => { const { find } = testBed; return find('deleteDataStreamsConfirmation'); @@ -194,6 +202,7 @@ export const setup = async (overridingDependencies: any = {}): Promise): DataSt indexTemplateName: 'indexTemplate', storageSize: '1b', maxTimeStamp: 420, + privileges: { + delete_index: true, + }, ...dataStream, }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 8ce307c103f4c..91502621d50c5 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -449,4 +449,77 @@ describe('Data Streams tab', () => { expect(tableCellsValues).toEqual([['', 'non-managed-data-stream', 'green', '1', 'Delete']]); }); }); + + describe('data stream privileges', () => { + describe('delete', () => { + const { setLoadDataStreamsResponse, setLoadDataStreamResponse } = httpRequestsMockHelpers; + + const dataStreamWithDelete = createDataStreamPayload({ + name: 'dataStreamWithDelete', + privileges: { delete_index: true }, + }); + const dataStreamNoDelete = createDataStreamPayload({ + name: 'dataStreamNoDelete', + privileges: { delete_index: false }, + }); + + beforeEach(async () => { + setLoadDataStreamsResponse([dataStreamWithDelete, dataStreamNoDelete]); + + testBed = await setup({ history: createMemoryHistory() }); + await act(async () => { + testBed.actions.goToDataStreamsList(); + }); + testBed.component.update(); + }); + + test('displays/hides delete button depending on data streams privileges', async () => { + const { table } = testBed; + const { tableCellsValues } = table.getMetaData('dataStreamTable'); + + expect(tableCellsValues).toEqual([ + ['', 'dataStreamNoDelete', 'green', '1', ''], + ['', 'dataStreamWithDelete', 'green', '1', 'Delete'], + ]); + }); + + test('displays/hides delete action depending on data streams privileges', async () => { + const { + actions: { selectDataStream }, + find, + } = testBed; + + selectDataStream('dataStreamNoDelete', true); + expect(find('deleteDataStreamsButton').exists()).toBeFalsy(); + + selectDataStream('dataStreamWithDelete', true); + expect(find('deleteDataStreamsButton').exists()).toBeFalsy(); + + selectDataStream('dataStreamNoDelete', false); + expect(find('deleteDataStreamsButton').exists()).toBeTruthy(); + }); + + test('displays delete button in detail panel', async () => { + const { + actions: { clickNameAt }, + find, + } = testBed; + setLoadDataStreamResponse(dataStreamWithDelete); + await clickNameAt(1); + + expect(find('deleteDataStreamButton').exists()).toBeTruthy(); + }); + + test('hides delete button in detail panel', async () => { + const { + actions: { clickNameAt }, + find, + } = testBed; + setLoadDataStreamResponse(dataStreamNoDelete); + await clickNameAt(0); + + expect(find('deleteDataStreamButton').exists()).toBeFalsy(); + }); + }); + }); }); diff --git a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts index 2d8e038d2a60f..fe7db99c98db1 100644 --- a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts @@ -18,6 +18,7 @@ export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataS store_size: storageSize, maximum_timestamp: maxTimeStamp, _meta, + privileges, } = dataStreamFromEs; return { @@ -37,6 +38,7 @@ export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataS storageSize, maxTimeStamp, _meta, + privileges, }; } diff --git a/x-pack/plugins/index_management/common/types/data_streams.ts b/x-pack/plugins/index_management/common/types/data_streams.ts index adb7104043fbb..fdfe6278eb985 100644 --- a/x-pack/plugins/index_management/common/types/data_streams.ts +++ b/x-pack/plugins/index_management/common/types/data_streams.ts @@ -10,13 +10,19 @@ interface TimestampFieldFromEs { type TimestampField = TimestampFieldFromEs; -interface MetaFieldFromEs { +interface MetaFromEs { managed_by: string; package: any; managed: boolean; } -type MetaField = MetaFieldFromEs; +type Meta = MetaFromEs; + +interface PrivilegesFromEs { + delete_index: boolean; +} + +type Privileges = PrivilegesFromEs; export type HealthFromEs = 'GREEN' | 'YELLOW' | 'RED'; @@ -25,12 +31,13 @@ export interface DataStreamFromEs { timestamp_field: TimestampFieldFromEs; indices: DataStreamIndexFromEs[]; generation: number; - _meta?: MetaFieldFromEs; + _meta?: MetaFromEs; status: HealthFromEs; template: string; ilm_policy?: string; store_size?: string; maximum_timestamp?: number; + privileges: PrivilegesFromEs; } export interface DataStreamIndexFromEs { @@ -50,7 +57,8 @@ export interface DataStream { ilmPolicyName?: string; storageSize?: string; maxTimeStamp?: number; - _meta?: MetaField; + _meta?: Meta; + privileges: Privileges; } export interface DataStreamIndex { diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index 05d7e97745b9e..ec47b2c062aa9 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -290,7 +290,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ - {!isLoading && !error ? ( + {!isLoading && !error && dataStream?.privileges.delete_index ? ( { const { isDeepLink } = extractQueryParams(search); + const decodedDataStreamName = attemptToURIDecode(dataStreamName); const { core: { getUrlForApp }, @@ -241,8 +242,8 @@ export const DataStreamList: React.FunctionComponent { history.push(`/${Section.DataStreams}`); diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx index c1fd33a39569c..7a3e719d013c8 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_table/data_stream_table.tsx @@ -162,6 +162,7 @@ export const DataStreamTable: React.FunctionComponent = ({ }, isPrimary: true, 'data-test-subj': 'deleteDataStream', + available: ({ privileges: { delete_index: deleteIndex } }: DataStream) => deleteIndex, }, ], }); @@ -188,9 +189,10 @@ export const DataStreamTable: React.FunctionComponent = ({ incremental: true, }, toolsLeft: - selection.length > 0 ? ( + selection.length > 0 && + selection.every((dataStream: DataStream) => dataStream.privileges.delete_index) ? ( setDataStreamsToDelete(selection.map(({ name }: DataStream) => name))} color="danger" > diff --git a/x-pack/plugins/index_management/server/client/elasticsearch.ts b/x-pack/plugins/index_management/server/client/elasticsearch.ts index ed5ede07479ca..8b7749335131e 100644 --- a/x-pack/plugins/index_management/server/client/elasticsearch.ts +++ b/x-pack/plugins/index_management/server/client/elasticsearch.ts @@ -11,28 +11,6 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) const dataManagement = Client.prototype.dataManagement.prototype; // Data streams - dataManagement.getDataStreams = ca({ - urls: [ - { - fmt: '/_data_stream', - }, - ], - method: 'GET', - }); - - dataManagement.getDataStream = ca({ - urls: [ - { - fmt: '/_data_stream/<%=name%>', - req: { - name: { - type: 'string', - }, - }, - }, - ], - method: 'GET', - }); // We don't allow the user to create a data stream in the UI or API. We're just adding this here // to enable the API integration tests. diff --git a/x-pack/plugins/index_management/server/plugin.ts b/x-pack/plugins/index_management/server/plugin.ts index ae9633f3e22b9..3d70140fa60b7 100644 --- a/x-pack/plugins/index_management/server/plugin.ts +++ b/x-pack/plugins/index_management/server/plugin.ts @@ -24,7 +24,7 @@ import { PLUGIN } from '../common'; import { Dependencies } from './types'; import { ApiRoutes } from './routes'; import { License, IndexDataEnricher } from './services'; -import { isEsError } from './shared_imports'; +import { isEsError, handleEsError } from './shared_imports'; import { elasticsearchJsPlugin } from './client/elasticsearch'; export interface DataManagementContext { @@ -110,6 +110,7 @@ export class IndexMgmtServerPlugin implements Plugin { indexDataEnricher: mockedIndexDataEnricher, lib: { isEsError: jest.fn(), + handleEsError: jest.fn(), }, }); @@ -123,6 +124,7 @@ describe('GET privileges', () => { indexDataEnricher: mockedIndexDataEnricher, lib: { isEsError: jest.fn(), + handleEsError: jest.fn(), }, }); diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts index fa93d9bd0c563..d19383d892cbd 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -6,122 +6,189 @@ import { schema, TypeOf } from '@kbn/config-schema'; +import { ElasticsearchClient } from 'kibana/server'; import { deserializeDataStream, deserializeDataStreamList } from '../../../../common/lib'; +import { DataStreamFromEs } from '../../../../common/types'; import { RouteDependencies } from '../../../types'; import { addBasePath } from '../index'; -const querySchema = schema.object({ - includeStats: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), -}); +interface PrivilegesFromEs { + username: string; + has_all_requested: boolean; + cluster: Record; + index: Record>; + application: Record; +} + +interface StatsFromEs { + data_stream: string; + store_size: string; + maximum_timestamp: number; +} -export function registerGetAllRoute({ router, license, lib: { isEsError } }: RouteDependencies) { +const enhanceDataStreams = ({ + dataStreams, + dataStreamsStats, + dataStreamsPrivileges, +}: { + dataStreams: DataStreamFromEs[]; + dataStreamsStats?: StatsFromEs[]; + dataStreamsPrivileges?: PrivilegesFromEs; +}): DataStreamFromEs[] => { + return dataStreams.map((dataStream: DataStreamFromEs) => { + let enhancedDataStream = { ...dataStream }; + + if (dataStreamsStats) { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { store_size, maximum_timestamp } = + dataStreamsStats.find( + ({ data_stream: statsName }: { data_stream: string }) => statsName === dataStream.name + ) || {}; + + enhancedDataStream = { + ...enhancedDataStream, + store_size, + maximum_timestamp, + }; + } + + enhancedDataStream = { + ...enhancedDataStream, + privileges: { + delete_index: dataStreamsPrivileges + ? dataStreamsPrivileges.index[dataStream.name].delete_index + : true, + }, + }; + + return enhancedDataStream; + }); +}; + +const getDataStreamsStats = (client: ElasticsearchClient, name = '*') => { + return client.transport.request({ + path: `/_data_stream/${encodeURIComponent(name)}/_stats`, + method: 'GET', + querystring: { + human: true, + }, + }); +}; + +const getDataStreamsPrivileges = (client: ElasticsearchClient, names: string[]) => { + return client.security.hasPrivileges({ + body: { + index: [ + { + names, + privileges: ['delete_index'], + }, + ], + }, + }); +}; + +export function registerGetAllRoute({ + router, + license, + lib: { handleEsError }, + config, +}: RouteDependencies) { + const querySchema = schema.object({ + includeStats: schema.maybe(schema.oneOf([schema.literal('true'), schema.literal('false')])), + }); router.get( { path: addBasePath('/data_streams'), validate: { query: querySchema } }, - license.guardApiRoute(async (ctx, req, res) => { - const { callAsCurrentUser } = ctx.dataManagement!.client; + license.guardApiRoute(async (ctx, req, response) => { + const { asCurrentUser } = ctx.core.elasticsearch.client; const includeStats = (req.query as TypeOf).includeStats === 'true'; try { - const { data_streams: dataStreams } = await callAsCurrentUser( - 'dataManagement.getDataStreams' - ); - - if (includeStats) { - const { - data_streams: dataStreamsStats, - } = await ctx.core.elasticsearch.legacy.client.callAsCurrentUser('transport.request', { - path: '/_data_stream/*/_stats', - method: 'GET', - query: { - human: true, - }, - }); + let { + body: { data_streams: dataStreams }, + } = await asCurrentUser.indices.getDataStream(); - // Merge stats into data streams. - for (let i = 0; i < dataStreams.length; i++) { - const dataStream = dataStreams[i]; + let dataStreamsStats; + let dataStreamsPrivileges; - // eslint-disable-next-line @typescript-eslint/naming-convention - const { store_size, maximum_timestamp } = dataStreamsStats.find( - ({ data_stream: statsName }: { data_stream: string }) => statsName === dataStream.name - ); - - dataStreams[i] = { - ...dataStream, - store_size, - maximum_timestamp, - }; - } + if (includeStats) { + ({ + body: { data_streams: dataStreamsStats }, + } = await getDataStreamsStats(asCurrentUser)); } - return res.ok({ body: deserializeDataStreamList(dataStreams) }); - } catch (error) { - if (isEsError(error)) { - return res.customError({ - statusCode: error.statusCode, - body: error, - }); + if (config.isSecurityEnabled() && dataStreams.length > 0) { + ({ body: dataStreamsPrivileges } = await getDataStreamsPrivileges( + asCurrentUser, + dataStreams.map((dataStream: DataStreamFromEs) => dataStream.name) + )); } - return res.internalError({ body: error }); + dataStreams = enhanceDataStreams({ + dataStreams, + dataStreamsStats, + dataStreamsPrivileges, + }); + + return response.ok({ body: deserializeDataStreamList(dataStreams) }); + } catch (error) { + return handleEsError({ error, response }); } }) ); } -export function registerGetOneRoute({ router, license, lib: { isEsError } }: RouteDependencies) { +export function registerGetOneRoute({ + router, + license, + lib: { handleEsError }, + config, +}: RouteDependencies) { const paramsSchema = schema.object({ name: schema.string(), }); - router.get( { path: addBasePath('/data_streams/{name}'), validate: { params: paramsSchema }, }, - license.guardApiRoute(async (ctx, req, res) => { + license.guardApiRoute(async (ctx, req, response) => { const { name } = req.params as TypeOf; - const { callAsCurrentUser } = ctx.dataManagement!.client; + const { asCurrentUser } = ctx.core.elasticsearch.client; try { const [ - { data_streams: dataStream }, - { data_streams: dataStreamsStats }, + { + body: { data_streams: dataStreams }, + }, + { + body: { data_streams: dataStreamsStats }, + }, ] = await Promise.all([ - callAsCurrentUser('dataManagement.getDataStream', { - name, - }), - ctx.core.elasticsearch.legacy.client.callAsCurrentUser('transport.request', { - path: `/_data_stream/${encodeURIComponent(name)}/_stats`, - method: 'GET', - query: { - human: true, - }, - }), + asCurrentUser.indices.getDataStream({ name }), + getDataStreamsStats(asCurrentUser, name), ]); - if (dataStream[0]) { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { store_size, maximum_timestamp } = dataStreamsStats[0]; - dataStream[0] = { - ...dataStream[0], - store_size, - maximum_timestamp, - }; - const body = deserializeDataStream(dataStream[0]); - return res.ok({ body }); - } + if (dataStreams[0]) { + let dataStreamsPrivileges; + if (config.isSecurityEnabled()) { + ({ body: dataStreamsPrivileges } = await getDataStreamsPrivileges(asCurrentUser, [ + dataStreams[0].name, + ])); + } - return res.notFound(); - } catch (e) { - if (isEsError(e)) { - return res.customError({ - statusCode: e.statusCode, - body: e, + const enhancedDataStreams = enhanceDataStreams({ + dataStreams, + dataStreamsStats, + dataStreamsPrivileges, }); + const body = deserializeDataStream(enhancedDataStreams[0]); + return response.ok({ body }); } - // Case: default - return res.internalError({ body: e }); + + return response.notFound(); + } catch (error) { + return handleEsError({ error, response }); } }) ); diff --git a/x-pack/plugins/index_management/server/shared_imports.ts b/x-pack/plugins/index_management/server/shared_imports.ts index 454beda5394c7..0606f474897b5 100644 --- a/x-pack/plugins/index_management/server/shared_imports.ts +++ b/x-pack/plugins/index_management/server/shared_imports.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { isEsError } from '../../../../src/plugins/es_ui_shared/server'; +export { isEsError, handleEsError } from '../../../../src/plugins/es_ui_shared/server'; diff --git a/x-pack/plugins/index_management/server/types.ts b/x-pack/plugins/index_management/server/types.ts index 7aa91629f0a47..177dedeb87bb4 100644 --- a/x-pack/plugins/index_management/server/types.ts +++ b/x-pack/plugins/index_management/server/types.ts @@ -8,7 +8,7 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../features/serve import { LicensingPluginSetup } from '../../licensing/server'; import { SecurityPluginSetup } from '../../security/server'; import { License, IndexDataEnricher } from './services'; -import { isEsError } from './shared_imports'; +import { isEsError, handleEsError } from './shared_imports'; export interface Dependencies { security: SecurityPluginSetup; @@ -25,6 +25,7 @@ export interface RouteDependencies { indexDataEnricher: IndexDataEnricher; lib: { isEsError: typeof isEsError; + handleEsError: typeof handleEsError; }; } diff --git a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts index f4b947336e044..6cf1a40a4d5a1 100644 --- a/x-pack/test/api_integration/apis/management/index_management/data_streams.ts +++ b/x-pack/test/api_integration/apis/management/index_management/data_streams.ts @@ -77,6 +77,9 @@ export default function ({ getService }: FtrProviderContext) { expect(dataStreams).to.eql([ { name: testDataStreamName, + privileges: { + delete_index: true, + }, timeStampField: { name: '@timestamp' }, indices: [ { @@ -105,6 +108,9 @@ export default function ({ getService }: FtrProviderContext) { expect(dataStreams.length).to.be(1); expect(dataStreamWithoutStorageSize).to.eql({ name: testDataStreamName, + privileges: { + delete_index: true, + }, timeStampField: { name: '@timestamp' }, indices: [ { @@ -132,6 +138,9 @@ export default function ({ getService }: FtrProviderContext) { expect(dataStreamWithoutStorageSize).to.eql({ name: testDataStreamName, + privileges: { + delete_index: true, + }, timeStampField: { name: '@timestamp' }, indices: [ {