[8.x] [Data Usage] add error handling and tests for privilege related errors (#203006) (#203252)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Data Usage] add error handling and tests for privilege related
errors (#203006)](https://github.com/elastic/kibana/pull/203006)

<!--- Backport version: 9.4.3 -->

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

<!--BACKPORT [{"author":{"name":"Sandra
G","email":"neptunian@users.noreply.github.com"},"sourceCommit":{"committedDate":"2024-12-06T15:58:20Z","message":"[Data
Usage] add error handling and tests for privilege related errors
(#203006)\n\n- handling of 2 error cases to error handler\r\n-
`security_exception` due to lack of privileges. Metering api
will\r\nrespond when one of the following isn't available as a user
privilege\r\n`monitor,view_index_metadata,manage,all`.\r\n-
`index_not_found_exception`. Metering api will respond with this
when\r\nno indices exist for the privileges it has access to or when no
indices\r\nare found.\r\n- api integration tests for data_streams route
for the following cases\r\n- returns no data streams when there are none
it has access to and\r\nresponds with appropriate message\r\n- returns
no data streams without necessary privileges and responds
with\r\nappropriate message\r\n- returns data streams when user only has
access to a subset of indices\r\nwith necessary privileges\r\n-
functional tests for same as above. these remain skipped due to
not\r\nbeing able to create data streams picked up by metering api since
we\r\nimplemented filtering out zero storage size data streams, but
useful for\r\nlocal testing with some code changes.\r\n\r\n\r\n###
`security_exception` view\r\n<img width=\"1555\" alt=\"Screenshot
2024-12-04 at 1 14
10 PM\"\r\nsrc=\"https://github.com/user-attachments/assets/241a2eb8-1c77-4592-ba18-b971512e712e\">\r\n\r\n###
`index_not_found_exception` view\r\n<img width=\"1589\" alt=\"Screenshot
2024-12-04 at 1 13
13 PM\"\r\nsrc=\"https://github.com/user-attachments/assets/12b68d66-9178-4957-b014-5765be348694\">\r\n\r\n---------\r\n\r\nCo-authored-by:
Ashokaditya <ashokaditya@elastic.co>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"42348d41f43a2e69c93b0e7a6b2f372bc96fd059","branchLabelMapping":{"^v9.0.0$":"main","^v8.18.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v9.0.0","backport:prev-minor","ci:build-serverless-image"],"title":"[Data
Usage] add error handling and tests for privilege related
errors","number":203006,"url":"https://github.com/elastic/kibana/pull/203006","mergeCommit":{"message":"[Data
Usage] add error handling and tests for privilege related errors
(#203006)\n\n- handling of 2 error cases to error handler\r\n-
`security_exception` due to lack of privileges. Metering api
will\r\nrespond when one of the following isn't available as a user
privilege\r\n`monitor,view_index_metadata,manage,all`.\r\n-
`index_not_found_exception`. Metering api will respond with this
when\r\nno indices exist for the privileges it has access to or when no
indices\r\nare found.\r\n- api integration tests for data_streams route
for the following cases\r\n- returns no data streams when there are none
it has access to and\r\nresponds with appropriate message\r\n- returns
no data streams without necessary privileges and responds
with\r\nappropriate message\r\n- returns data streams when user only has
access to a subset of indices\r\nwith necessary privileges\r\n-
functional tests for same as above. these remain skipped due to
not\r\nbeing able to create data streams picked up by metering api since
we\r\nimplemented filtering out zero storage size data streams, but
useful for\r\nlocal testing with some code changes.\r\n\r\n\r\n###
`security_exception` view\r\n<img width=\"1555\" alt=\"Screenshot
2024-12-04 at 1 14
10 PM\"\r\nsrc=\"https://github.com/user-attachments/assets/241a2eb8-1c77-4592-ba18-b971512e712e\">\r\n\r\n###
`index_not_found_exception` view\r\n<img width=\"1589\" alt=\"Screenshot
2024-12-04 at 1 13
13 PM\"\r\nsrc=\"https://github.com/user-attachments/assets/12b68d66-9178-4957-b014-5765be348694\">\r\n\r\n---------\r\n\r\nCo-authored-by:
Ashokaditya <ashokaditya@elastic.co>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"42348d41f43a2e69c93b0e7a6b2f372bc96fd059"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/203006","number":203006,"mergeCommit":{"message":"[Data
Usage] add error handling and tests for privilege related errors
(#203006)\n\n- handling of 2 error cases to error handler\r\n-
`security_exception` due to lack of privileges. Metering api
will\r\nrespond when one of the following isn't available as a user
privilege\r\n`monitor,view_index_metadata,manage,all`.\r\n-
`index_not_found_exception`. Metering api will respond with this
when\r\nno indices exist for the privileges it has access to or when no
indices\r\nare found.\r\n- api integration tests for data_streams route
for the following cases\r\n- returns no data streams when there are none
it has access to and\r\nresponds with appropriate message\r\n- returns
no data streams without necessary privileges and responds
with\r\nappropriate message\r\n- returns data streams when user only has
access to a subset of indices\r\nwith necessary privileges\r\n-
functional tests for same as above. these remain skipped due to
not\r\nbeing able to create data streams picked up by metering api since
we\r\nimplemented filtering out zero storage size data streams, but
useful for\r\nlocal testing with some code changes.\r\n\r\n\r\n###
`security_exception` view\r\n<img width=\"1555\" alt=\"Screenshot
2024-12-04 at 1 14
10 PM\"\r\nsrc=\"https://github.com/user-attachments/assets/241a2eb8-1c77-4592-ba18-b971512e712e\">\r\n\r\n###
`index_not_found_exception` view\r\n<img width=\"1589\" alt=\"Screenshot
2024-12-04 at 1 13
13 PM\"\r\nsrc=\"https://github.com/user-attachments/assets/12b68d66-9178-4957-b014-5765be348694\">\r\n\r\n---------\r\n\r\nCo-authored-by:
Ashokaditya <ashokaditya@elastic.co>\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"42348d41f43a2e69c93b0e7a6b2f372bc96fd059"}}]}]
BACKPORT-->

Co-authored-by: Sandra G <neptunian@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2024-12-07 04:47:06 +11:00 committed by GitHub
parent 0e3f28ae8c
commit 89d7947183
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 345 additions and 20 deletions

View file

@ -5,7 +5,7 @@
* 2.0.
*/
export class BaseError<MetaType = unknown> extends Error {
export class DataUsageError<MetaType = unknown> extends Error {
constructor(message: string, public readonly meta?: MetaType) {
super(message);
// For debugging - capture name of subclasses

View file

@ -0,0 +1,30 @@
/*
* 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 max-classes-per-file */
import { DataUsageError } from './common/errors';
export class NotFoundError extends DataUsageError {}
export class AutoOpsError extends DataUsageError {}
export class NoPrivilegeMeteringError extends DataUsageError {
constructor() {
super(
'You do not have the necessary privileges to access data stream statistics. Please contact your administrator.'
);
}
}
export class NoIndicesMeteringError extends DataUsageError {
constructor() {
super(
'No data streams or indices are available for the current user. Ensure that the data streams or indices you are authorized to access have been created and contain data. If you believe this is an error, please contact your administrator.'
);
}
}

View file

@ -7,10 +7,12 @@
import type { IKibanaResponse, KibanaResponseFactory, Logger } from '@kbn/core/server';
import { CustomHttpRequestError } from '../utils/custom_http_request_error';
import { BaseError } from '../common/errors';
import { AutoOpsError } from '../services/errors';
export class NotFoundError extends BaseError {}
import {
AutoOpsError,
NoPrivilegeMeteringError,
NoIndicesMeteringError,
NotFoundError,
} from '../errors';
/**
* Default Data Usage Routes error handler
@ -43,6 +45,14 @@ export const errorHandler = <E extends Error>(
return res.notFound({ body: error });
}
if (error instanceof NoPrivilegeMeteringError) {
return res.forbidden({ body: error });
}
if (error instanceof NoIndicesMeteringError) {
return res.notFound({ body: error });
}
// Kibana CORE will take care of `500` errors when the handler `throw`'s, including logging the error
throw error;
};

View file

@ -19,6 +19,7 @@ import { DATA_USAGE_DATA_STREAMS_API_ROUTE } from '../../../common';
import { createMockedDataUsageContext } from '../../mocks';
import { getMeteringStats } from '../../utils/get_metering_stats';
import { CustomHttpRequestError } from '../../utils';
import { NoIndicesMeteringError, NoPrivilegeMeteringError } from '../../errors';
jest.mock('../../utils/get_metering_stats');
const mockGetMeteringStats = getMeteringStats as jest.Mock;
@ -126,7 +127,7 @@ describe('registerDataStreamsRoute', () => {
});
});
it('should return correct error if metering stats request fails', async () => {
it('should return correct error if metering stats request fails with an unknown error', async () => {
// using custom error for test here to avoid having to import the actual error class
mockGetMeteringStats.mockRejectedValue(
new CustomHttpRequestError('Error getting metring stats!')
@ -144,6 +145,38 @@ describe('registerDataStreamsRoute', () => {
});
});
it('should return `not found` error if metering stats request fails when no indices', async () => {
mockGetMeteringStats.mockRejectedValue(
new Error(JSON.stringify({ message: 'index_not_found_exception' }))
);
const mockRequest = httpServerMock.createKibanaRequest({ body: {} });
const mockResponse = httpServerMock.createResponseFactory();
const mockRouter = mockCore.http.createRouter.mock.results[0].value;
const [[, handler]] = mockRouter.versioned.get.mock.results[0].value.addVersion.mock.calls;
await handler(context, mockRequest, mockResponse);
expect(mockResponse.notFound).toHaveBeenCalledTimes(1);
expect(mockResponse.notFound).toHaveBeenCalledWith({
body: new NoIndicesMeteringError(),
});
});
it('should return `forbidden` error if metering stats request fails with privileges error', async () => {
mockGetMeteringStats.mockRejectedValue(
new Error(JSON.stringify({ message: 'security_exception' }))
);
const mockRequest = httpServerMock.createKibanaRequest({ body: {} });
const mockResponse = httpServerMock.createResponseFactory();
const mockRouter = mockCore.http.createRouter.mock.results[0].value;
const [[, handler]] = mockRouter.versioned.get.mock.results[0].value.addVersion.mock.calls;
await handler(context, mockRequest, mockResponse);
expect(mockResponse.forbidden).toHaveBeenCalledTimes(1);
expect(mockResponse.forbidden).toHaveBeenCalledWith({
body: new NoPrivilegeMeteringError(),
});
});
it.each([
['no datastreams', {}, []],
['empty array', { datastreams: [] }, []],

View file

@ -10,6 +10,7 @@ import { DataUsageContext, DataUsageRequestHandlerContext } from '../../types';
import { errorHandler } from '../error_handler';
import { getMeteringStats } from '../../utils/get_metering_stats';
import { DataStreamsRequestQuery } from '../../../common/rest_types/data_streams';
import { NoIndicesMeteringError, NoPrivilegeMeteringError } from '../../errors';
export const getDataStreamsHandler = (
dataUsageContext: DataUsageContext
@ -45,6 +46,12 @@ export const getDataStreamsHandler = (
body,
});
} catch (error) {
if (error.message.includes('security_exception')) {
return errorHandler(logger, response, new NoPrivilegeMeteringError());
} else if (error.message.includes('index_not_found_exception')) {
return errorHandler(logger, response, new NoIndicesMeteringError());
}
return errorHandler(logger, response, error);
}
};

View file

@ -18,7 +18,7 @@ import type {
import { DATA_USAGE_METRICS_API_ROUTE } from '../../../common';
import { createMockedDataUsageContext } from '../../mocks';
import { CustomHttpRequestError } from '../../utils';
import { AutoOpsError } from '../../services/errors';
import { AutoOpsError } from '../../errors';
import { transformToUTCtime } from '../../../common/utils';
const timeRange = {

View file

@ -21,7 +21,7 @@ import {
type UsageMetricsRequestBody,
} from '../../common/rest_types';
import { AutoOpsConfig } from '../types';
import { AutoOpsError } from './errors';
import { AutoOpsError } from '../errors';
import { appContextService } from './app_context';
const AUTO_OPS_REQUEST_FAILED_PREFIX = '[AutoOps API] Request failed';

View file

@ -1,10 +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 { BaseError } from '../common/errors';
export class AutoOpsError extends BaseError {}

View file

@ -7,7 +7,7 @@
import { ValidationError } from '@kbn/config-schema';
import { Logger } from '@kbn/logging';
import type { MetricTypes } from '../../common/rest_types';
import { AutoOpsError } from './errors';
import { AutoOpsError } from '../errors';
import { AutoOpsAPIService } from './autoops_api';
export class DataUsageService {

View file

@ -11,6 +11,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('Serverless Data Usage APIs', function () {
this.tags(['esGate']);
loadTestFile(require.resolve('./tests/data_streams_privileges'));
loadTestFile(require.resolve('./tests/data_streams'));
loadTestFile(require.resolve('./tests/metrics'));
});

View file

@ -0,0 +1,155 @@
/*
* 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 expect from '@kbn/expect';
import { DataStreamsResponseBodySchemaBody } from '@kbn/data-usage-plugin/common/rest_types';
import { DATA_USAGE_DATA_STREAMS_API_ROUTE } from '@kbn/data-usage-plugin/common';
import type { RoleCredentials } from '@kbn/ftr-common-functional-services';
import {
NoIndicesMeteringError,
NoPrivilegeMeteringError,
} from '@kbn/data-usage-plugin/server/errors';
import { FtrProviderContext } from '../../../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const svlDatastreamsHelpers = getService('svlDatastreamsHelpers');
const svlCommonApi = getService('svlCommonApi');
const samlAuth = getService('samlAuth');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const testDataStreamName = 'test-data-stream';
const otherTestDataStreamName = 'other-test-data-stream';
let roleAuthc: RoleCredentials;
describe('privileges with custom roles', function () {
// custom role testing is not supported in MKI
// the metering api which this route calls requires one of: monitor,view_index_metadata,manage,all
this.tags(['skipSvlOblt', 'skipMKI']);
before(async () => {
await svlDatastreamsHelpers.createDataStream(testDataStreamName);
await svlDatastreamsHelpers.createDataStream(otherTestDataStreamName);
});
after(async () => {
await svlDatastreamsHelpers.deleteDataStream(testDataStreamName);
await svlDatastreamsHelpers.deleteDataStream(otherTestDataStreamName);
});
afterEach(async () => {
await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc);
await samlAuth.deleteCustomRole();
});
it('returns all data streams for indices with necessary privileges', async () => {
await samlAuth.setCustomRole({
elasticsearch: {
indices: [{ names: ['*'], privileges: ['all'] }],
},
kibana: [
{
base: ['all'],
feature: {},
spaces: ['*'],
},
],
});
roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('customRole');
const res = await supertestWithoutAuth
.get(DATA_USAGE_DATA_STREAMS_API_ROUTE)
.query({ includeZeroStorage: true })
.set(svlCommonApi.getInternalRequestHeader())
.set(roleAuthc.apiKeyHeader)
.set('elastic-api-version', '1');
const dataStreams: DataStreamsResponseBodySchemaBody = res.body;
const foundTestDataStream = dataStreams.find((stream) => stream.name === testDataStreamName);
const foundTestDataStream2 = dataStreams.find(
(stream) => stream.name === otherTestDataStreamName
);
expect(res.statusCode).to.be(200);
expect(foundTestDataStream?.name).to.be(testDataStreamName);
expect(foundTestDataStream2?.name).to.be(otherTestDataStreamName);
});
it('returns data streams for only a subset of indices with necessary privileges', async () => {
await samlAuth.setCustomRole({
elasticsearch: {
indices: [{ names: ['test-data-stream*'], privileges: ['all'] }],
},
kibana: [
{
base: ['all'],
feature: {},
spaces: ['*'],
},
],
});
roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('customRole');
const res = await supertestWithoutAuth
.get(DATA_USAGE_DATA_STREAMS_API_ROUTE)
.query({ includeZeroStorage: true })
.set(svlCommonApi.getInternalRequestHeader())
.set(roleAuthc.apiKeyHeader)
.set('elastic-api-version', '1');
const dataStreams: DataStreamsResponseBodySchemaBody = res.body;
const foundTestDataStream = dataStreams.find((stream) => stream.name === testDataStreamName);
const dataStreamNoPermission = dataStreams.find(
(stream) => stream.name === otherTestDataStreamName
);
expect(res.statusCode).to.be(200);
expect(foundTestDataStream?.name).to.be(testDataStreamName);
expect(dataStreamNoPermission?.name).to.be(undefined);
});
it('returns no data streams without necessary privileges', async () => {
await samlAuth.setCustomRole({
elasticsearch: {
indices: [{ names: ['*'], privileges: ['write'] }],
},
kibana: [
{
base: ['all'],
feature: {},
spaces: ['*'],
},
],
});
roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('customRole');
const res = await supertestWithoutAuth
.get(DATA_USAGE_DATA_STREAMS_API_ROUTE)
.query({ includeZeroStorage: true })
.set(svlCommonApi.getInternalRequestHeader())
.set(roleAuthc.apiKeyHeader)
.set('elastic-api-version', '1');
expect(res.statusCode).to.be(403);
expect(res.body.message).to.contain(new NoPrivilegeMeteringError().message);
});
it('returns no data streams when there are none it has access to', async () => {
await samlAuth.setCustomRole({
elasticsearch: {
indices: [{ names: ['none*'], privileges: ['all'] }],
},
kibana: [
{
base: ['all'],
feature: {},
spaces: ['*'],
},
],
});
roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('customRole');
const res = await supertestWithoutAuth
.get(DATA_USAGE_DATA_STREAMS_API_ROUTE)
.query({ includeZeroStorage: true })
.set(svlCommonApi.getInternalRequestHeader())
.set(roleAuthc.apiKeyHeader)
.set('elastic-api-version', '1');
expect(res.statusCode).to.be(404);
expect(res.body.message).to.contain(new NoIndicesMeteringError().message);
});
});
}

View file

@ -5,6 +5,11 @@
* 2.0.
*/
import expect from '@kbn/expect';
import {
NoIndicesMeteringError,
NoPrivilegeMeteringError,
} from '@kbn/data-usage-plugin/server/errors';
import { FtrProviderContext } from '../../../ftr_provider_context';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
@ -12,7 +17,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const testSubjects = getService('testSubjects');
const samlAuth = getService('samlAuth');
const retry = getService('retry');
const es = getService('es');
const dataUsageAppUrl = 'management/data/data_usage';
const toasts = getService('toasts');
const navigateAndVerify = async (expectedVisible: boolean) => {
await pageObjects.common.navigateToApp('management');
@ -32,6 +39,32 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
};
describe('privileges', function () {
before(async () => {
await es.indices.putIndexTemplate({
name: 'test-datastream',
body: {
index_patterns: ['test-datastream'],
data_stream: {},
priority: 200,
},
});
await es.indices.createDataStream({ name: 'test-datastream' });
await es.indices.putIndexTemplate({
name: 'no-permission-test-datastream',
body: {
index_patterns: ['no-permission-test-datastream'],
data_stream: {},
priority: 200,
},
});
await es.indices.createDataStream({ name: 'no-permission-test-datastream' });
});
after(async () => {
await es.indices.deleteDataStream({ name: 'test-datastream' });
await es.indices.deleteDataStream({ name: 'no-permission-test-datastream' });
});
it('renders for the admin role', async () => {
await pageObjects.svlCommonPage.loginAsAdmin();
await navigateAndVerify(true);
@ -63,7 +96,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
afterEach(async () => {
await samlAuth.deleteCustomRole();
});
it('renders with a custom role that has the monitor cluster privilege', async () => {
it('renders with a custom role that has the privileges cluster: monitor and indices all', async () => {
await samlAuth.setCustomRole({
elasticsearch: {
cluster: ['monitor'],
@ -97,6 +130,72 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await pageObjects.svlCommonPage.loginWithCustomRole();
await navigateAndVerify(false);
});
describe.skip('with custom role and data streams', function () {
// skip in all environments. requires a code change to the data_streams route
// to allow zero storage data streams to not be filtered out, but useful for testing.
// the api integration tests can pass a flag to get around this case but we can't in the UI.
// metering api requires one of: monitor,view_index_metadata,manage,all
it('does not load data streams without necessary index privilege for any index', async () => {
await samlAuth.setCustomRole({
elasticsearch: {
cluster: ['monitor'],
indices: [{ names: ['*'], privileges: ['read'] }],
},
kibana: [
{
base: ['all'],
feature: {},
spaces: ['*'],
},
],
});
await pageObjects.svlCommonPage.loginWithCustomRole();
await navigateAndVerify(true);
const toastContent = await toasts.getContentByIndex(1);
expect(toastContent).to.contain(NoPrivilegeMeteringError);
});
it('does load data streams with necessary index privilege for some indices', async () => {
await samlAuth.setCustomRole({
elasticsearch: {
cluster: ['monitor'],
indices: [
{ names: ['test-datastream*'], privileges: ['all'] },
{ names: ['.*'], privileges: ['read'] },
],
},
kibana: [
{
base: ['all'],
feature: {},
spaces: ['*'],
},
],
});
await pageObjects.svlCommonPage.loginWithCustomRole();
await navigateAndVerify(true);
});
it('handles error when no data streams that it has permission to exist (index_not_found_exception)', async () => {
await samlAuth.setCustomRole({
elasticsearch: {
cluster: ['monitor'],
indices: [{ names: ['none*'], privileges: ['all'] }],
},
kibana: [
{
base: ['all'],
feature: {},
spaces: ['*'],
},
],
});
await pageObjects.svlCommonPage.loginWithCustomRole();
await navigateAndVerify(true);
const toastContent = await toasts.getContentByIndex(1);
expect(toastContent).to.contain(NoIndicesMeteringError);
});
});
});
});
};