[SIEM][Detection Engine] Adds privileges API endpoint

## Summary

Adds a privileges API endpoint for the UI and people to query to check to see if their namespaced index is going to have the correct privileges or not.

Usage:

Testing:

Set up your user name and password to a test space for the CLI. Give whatever permissions
you want for restricted access to the test-space user, test-space role, and the test-space actual 
space to ensure everything works out as expected.

```sh
export ELASTICSEARCH_USERNAME=test-space
export ELASTICSEARCH_PASSWORD=(passwword)
export SPACE_URL=/s/test-space
```

Then use it like so

API:
```sh
GET /api/detection_engine/privileges
```

CLI:
```sh
./get_privileges.sh
```

Return will be something like this:

```sh
{
  "username": "test-space",
  "has_all_requested": false,
  "cluster": {
    "monitor_ml": true,
    "manage_ccr": false,
    "manage_index_templates": true,
    "monitor_watcher": true,
    "monitor_transform": true,
    "read_ilm": true,
    "manage_api_key": false,
    "manage_security": false,
    "manage_own_api_key": false,
    "manage_saml": false,
    "all": false,
    "manage_ilm": true,
    "manage_ingest_pipelines": true,
    "read_ccr": false,
    "manage_rollup": true,
    "monitor": true,
    "manage_watcher": true,
    "manage": true,
    "manage_transform": true,
    "manage_token": false,
    "manage_ml": true,
    "manage_pipeline": true,
    "monitor_rollup": true,
    "transport_client": true,
    "create_snapshot": true
  },
  "index": {
    ".siem-signals-test-space": {
      "all": false,
      "manage_ilm": true,
      "read": false,
      "create_index": true,
      "read_cross_cluster": false,
      "index": false,
      "monitor": true,
      "delete": false,
      "manage": true,
      "delete_index": true,
      "create_doc": false,
      "view_index_metadata": true,
      "create": false,
      "manage_follow_index": true,
      "manage_leader_index": true,
      "write": false
    }
  },
  "application": {}
}
```

Example permissions that work for managing all signal indexes across all spaces so that the user in question can create it for each space:

<img width="1274" alt="Screen Shot 2019-12-10 at 4 48 19 PM" src="https://user-images.githubusercontent.com/1151048/70579132-f63f8f80-1b6c-11ea-86f7-204fd2163cea.png">

Example permissions that work for managing only a specific signal index:
<img width="1234" alt="Screen Shot 2019-12-10 at 3 49 24 PM" src="https://user-images.githubusercontent.com/1151048/70579185-11aa9a80-1b6d-11ea-8a33-311e85ce5dc9.png">
 
Example permissions that work for an end user using signals across all spaces:
<img width="1229" alt="Screen Shot 2019-12-10 at 3 49 41 PM" src="https://user-images.githubusercontent.com/1151048/70579233-31da5980-1b6d-11ea-8b3a-85703ebcc57f.png">

Example permissions that work for an end user using signals for a a specific index:
<img width="1234" alt="Screen Shot 2019-12-10 at 3 49 24 PM" src="https://user-images.githubusercontent.com/1151048/70579259-43bbfc80-1b6d-11ea-94c1-8bbc65e621b2.png">


### Checklist

Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR.

~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~

~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~

~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~

- [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios

~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~

### For maintainers

~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~

- [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)
This commit is contained in:
Frank Hassanabad 2019-12-11 11:52:11 -07:00 committed by GitHub
parent 7658e9c631
commit f53e1a9dbd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 241 additions and 18 deletions

View file

@ -45,6 +45,7 @@ export const SIGNALS_ID = `${APP_ID}.signals`;
*/
export const DETECTION_ENGINE_URL = '/api/detection_engine';
export const DETECTION_ENGINE_RULES_URL = `${DETECTION_ENGINE_URL}/rules`;
export const DETECTION_ENGINE_PRIVILEGES_URL = `${DETECTION_ENGINE_URL}/privileges`;
export const DETECTION_ENGINE_INDEX_URL = `${DETECTION_ENGINE_URL}/index`;
/**

View file

@ -19,6 +19,7 @@ import { querySignalsRoute } from './lib/detection_engine/routes/signals/query_s
import { ServerFacade } from './types';
import { deleteIndexRoute } from './lib/detection_engine/routes/index/delete_index_route';
import { isAlertExecutor } from './lib/detection_engine/signals/types';
import { readPrivilegesRoute } from './lib/detection_engine/routes/privileges/read_privileges_route';
const APP_ID = 'siem';
@ -52,4 +53,7 @@ export const initServerWithKibana = (context: PluginInitializerContext, __legacy
createIndexRoute(__legacy);
readIndexRoute(__legacy);
deleteIndexRoute(__legacy);
// Privileges API to get the generic user privileges
readPrivilegesRoute(__legacy);
};

View file

@ -5,7 +5,7 @@
*/
import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch';
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
// See the reference(s) below on explanations about why -000001 was chosen and
// why the is_write_index is true as well as the bootstrapping step which is needed.

View file

@ -6,7 +6,7 @@
import { IndicesDeleteParams } from 'elasticsearch';
import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch';
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const deleteAllIndex = async (
callWithRequest: CallWithRequest<IndicesDeleteParams, CallClusterOptions, boolean>,

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const deletePolicy = async (
callWithRequest: CallWithRequest<{ path: string; method: 'DELETE' }, {}, unknown>,

View file

@ -6,7 +6,7 @@
import { IndicesDeleteTemplateParams } from 'elasticsearch';
import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch';
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const deleteTemplate = async (
callWithRequest: CallWithRequest<IndicesDeleteTemplateParams, CallClusterOptions, unknown>,

View file

@ -6,7 +6,7 @@
import { IndicesExistsParams } from 'elasticsearch';
import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch';
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const getIndexExists = async (
callWithRequest: CallWithRequest<IndicesExistsParams, CallClusterOptions, boolean>,

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const getPolicyExists = async (
callWithRequest: CallWithRequest<{ path: string; method: 'GET' }, {}, unknown>,

View file

@ -6,7 +6,7 @@
import { IndicesExistsTemplateParams } from 'elasticsearch';
import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch';
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const getTemplateExists = async (
callWithRequest: CallWithRequest<IndicesExistsTemplateParams, CallClusterOptions, boolean>,

View file

@ -6,7 +6,7 @@
import { IndicesGetSettingsParams } from 'elasticsearch';
import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch';
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const readIndex = async (
callWithRequest: CallWithRequest<IndicesGetSettingsParams, CallClusterOptions, unknown>,

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const setPolicy = async (
callWithRequest: CallWithRequest<{ path: string; method: 'PUT'; body: unknown }, {}, unknown>,

View file

@ -6,7 +6,7 @@
import { IndicesPutTemplateParams } from 'elasticsearch';
import { CallClusterOptions } from 'src/legacy/core_plugins/elasticsearch';
import { CallWithRequest } from './types';
import { CallWithRequest } from '../types';
export const setTemplate = async (
callWithRequest: CallWithRequest<IndicesPutTemplateParams, CallClusterOptions, unknown>,

View file

@ -1,7 +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;
* you may not use this file except in compliance with the Elastic License.
*/
export type CallWithRequest<T, U, V> = (endpoint: string, params: T, options?: U) => Promise<V>;

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CallWithRequest } from '../types';
export const readPrivileges = async (
callWithRequest: CallWithRequest<unknown, unknown, unknown>,
index: string
): Promise<unknown> => {
return callWithRequest('transport.request', {
path: `_security/user/_has_privileges`,
method: 'POST',
body: {
cluster: [
'all',
'create_snapshot',
'manage',
'manage_api_key',
'manage_ccr',
'manage_transform',
'manage_ilm',
'manage_index_templates',
'manage_ingest_pipelines',
'manage_ml',
'manage_own_api_key',
'manage_pipeline',
'manage_rollup',
'manage_saml',
'manage_security',
'manage_token',
'manage_watcher',
'monitor',
'monitor_transform',
'monitor_ml',
'monitor_rollup',
'monitor_watcher',
'read_ccr',
'read_ilm',
'transport_client',
],
index: [
{
names: [index],
privileges: [
'all',
'create',
'create_doc',
'create_index',
'delete',
'delete_index',
'index',
'manage',
'manage_follow_index',
'manage_ilm',
'manage_leader_index',
'monitor',
'read',
'read_cross_cluster',
'view_index_metadata',
'write',
],
},
],
},
});
};

View file

@ -10,6 +10,7 @@ import { SignalsStatusRestParams, SignalsQueryRestParams } from '../../signals/t
import {
DETECTION_ENGINE_RULES_URL,
DETECTION_ENGINE_SIGNALS_STATUS_URL,
DETECTION_ENGINE_PRIVILEGES_URL,
DETECTION_ENGINE_QUERY_SIGNALS_URL,
} from '../../../../../common/constants';
import { RuleAlertType } from '../../rules/types';
@ -81,6 +82,11 @@ export const getFindRequest = (): ServerInjectOptions => ({
url: `${DETECTION_ENGINE_RULES_URL}/_find`,
});
export const getPrivilegeRequest = (): ServerInjectOptions => ({
method: 'GET',
url: `${DETECTION_ENGINE_PRIVILEGES_URL}`,
});
interface FindHit {
page: number;
perPage: number;
@ -225,3 +231,56 @@ export const updateActionResult = (): ActionResult => ({
name: '',
config: {},
});
export const getMockPrivileges = () => ({
username: 'test-space',
has_all_requested: false,
cluster: {
monitor_ml: true,
manage_ccr: false,
manage_index_templates: true,
monitor_watcher: true,
monitor_transform: true,
read_ilm: true,
manage_api_key: false,
manage_security: false,
manage_own_api_key: false,
manage_saml: false,
all: false,
manage_ilm: true,
manage_ingest_pipelines: true,
read_ccr: false,
manage_rollup: true,
monitor: true,
manage_watcher: true,
manage: true,
manage_transform: true,
manage_token: false,
manage_ml: true,
manage_pipeline: true,
monitor_rollup: true,
transport_client: true,
create_snapshot: true,
},
index: {
'.siem-signals-frank-hassanabad-test-space': {
all: false,
manage_ilm: true,
read: false,
create_index: true,
read_cross_cluster: false,
index: false,
monitor: true,
delete: false,
manage: true,
delete_index: true,
create_doc: false,
view_index_metadata: true,
create: false,
manage_follow_index: true,
manage_leader_index: true,
write: false,
},
},
application: {},
});

View file

@ -48,7 +48,7 @@ export const createCreateIndexRoute = (server: ServerFacade): Hapi.ServerRoute =
const template = getSignalsTemplate(index);
await setTemplate(callWithRequest, index, template);
}
createBootstrapIndex(callWithRequest, index);
await createBootstrapIndex(callWithRequest, index);
return { acknowledged: true };
}
} catch (err) {

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createMockServer } from '../__mocks__/_mock_server';
import { getPrivilegeRequest, getMockPrivileges } from '../__mocks__/request_responses';
import { readPrivilegesRoute } from './read_privileges_route';
import * as myUtils from '../utils';
describe('read_privileges', () => {
let { server, elasticsearch } = createMockServer();
beforeEach(() => {
jest.spyOn(myUtils, 'getIndex').mockReturnValue('fakeindex');
({ server, elasticsearch } = createMockServer());
elasticsearch.getCluster = jest.fn(() => ({
callWithRequest: jest.fn(() => getMockPrivileges()),
}));
readPrivilegesRoute(server);
});
afterEach(() => {
jest.resetAllMocks();
});
describe('normal status codes', () => {
test('returns 200 when doing a normal request', async () => {
const { statusCode } = await server.inject(getPrivilegeRequest());
expect(statusCode).toBe(200);
});
test('returns the payload when doing a normal request', async () => {
const { payload } = await server.inject(getPrivilegeRequest());
expect(JSON.parse(payload)).toEqual(getMockPrivileges());
});
});
});

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Hapi from 'hapi';
import { DETECTION_ENGINE_PRIVILEGES_URL } from '../../../../../common/constants';
import { RulesRequest } from '../../rules/types';
import { ServerFacade } from '../../../../types';
import { callWithRequestFactory, transformError, getIndex } from '../utils';
import { readPrivileges } from '../../privileges/read_privileges';
export const createReadPrivilegesRulesRoute = (server: ServerFacade): Hapi.ServerRoute => {
return {
method: 'GET',
path: DETECTION_ENGINE_PRIVILEGES_URL,
options: {
tags: ['access:siem'],
validate: {
options: {
abortEarly: false,
},
},
},
async handler(request: RulesRequest) {
try {
const callWithRequest = callWithRequestFactory(request, server);
const index = getIndex(request, server);
const permissions = await readPrivileges(callWithRequest, index);
return permissions;
} catch (err) {
return transformError(err);
}
},
};
};
export const readPrivilegesRoute = (server: ServerFacade): void => {
server.route(createReadPrivilegesRulesRoute(server));
};

View file

@ -0,0 +1,15 @@
#!/bin/sh
#
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
# or more contributor license agreements. Licensed under the Elastic License;
# you may not use this file except in compliance with the Elastic License.
#
set -e
./check_env_variables.sh
# Example: ./get_privileges.sh
curl -s -k \
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-X GET ${KIBANA_URL}${SPACE_URL}/api/detection_engine/privileges | jq .

View file

@ -65,3 +65,5 @@ export type OutputRuleAlertRest = RuleAlertParamsRest & {
created_by: string | undefined | null;
updated_by: string | undefined | null;
};
export type CallWithRequest<T, U, V> = (endpoint: string, params: T, options?: U) => Promise<V>;