[Security Solution] [Security Platform] Allow users without any actions privileges to still import rules (#126203)

allow users without any actions privileges to still import rules, adds tests to cover this case

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Devin W. Hurley 2022-03-21 14:09:13 -04:00 committed by GitHub
parent bdc08fbf4f
commit ed7b51ffd6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 301 additions and 18 deletions

View file

@ -22,6 +22,7 @@ export type {
ActionType,
PreConfiguredAction,
ActionsApiRequestHandlerContext,
FindActionResult,
} from './types';
export type {

View file

@ -12,6 +12,7 @@ export enum ROLES {
t1_analyst = 't1_analyst',
t2_analyst = 't2_analyst',
hunter = 'hunter',
hunter_no_actions = 'hunter_no_actions',
rule_author = 'rule_author',
platform_engineer = 'platform_engineer',
detections_admin = 'detections_admin',

View file

@ -45,6 +45,7 @@ import {
} from './utils/import_rules_utils';
import { getReferencedExceptionLists } from './utils/gather_referenced_exceptions';
import { importRuleExceptions } from './utils/import_rule_exceptions';
import { ImportRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request';
const CHUNK_PARSED_OBJECT_SIZE = 50;
@ -138,22 +139,33 @@ export const importRulesRoute = (
actionSOClient
);
const [nonExistentActionErrors, uniqueParsedObjects] = await getInvalidConnectors(
migratedParsedObjectsWithoutDuplicateErrors,
actionsClient
let parsedRules;
let actionErrors: BulkError[] = [];
const actualRules = rules.filter(
(rule): rule is ImportRulesSchemaDecoded => !(rule instanceof Error)
);
if (actualRules.some((rule) => rule.actions.length > 0)) {
const [nonExistentActionErrors, uniqueParsedObjects] = await getInvalidConnectors(
migratedParsedObjectsWithoutDuplicateErrors,
actionsClient
);
parsedRules = uniqueParsedObjects;
actionErrors = nonExistentActionErrors;
} else {
parsedRules = migratedParsedObjectsWithoutDuplicateErrors;
}
// gather all exception lists that the imported rules reference
const foundReferencedExceptionLists = await getReferencedExceptionLists({
rules: uniqueParsedObjects,
rules: parsedRules,
savedObjectsClient,
});
const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects);
const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, parsedRules);
const importRuleResponse: ImportRuleResponse[] = await importRulesHelper({
ruleChunks: chunkParseObjects,
rulesResponseAcc: [...nonExistentActionErrors, ...duplicateIdErrors],
rulesResponseAcc: [...actionErrors, ...duplicateIdErrors],
mlAuthz,
overwriteRules: request.query.overwrite,
rulesClient,

View file

@ -16,7 +16,7 @@ import { RulesSchema } from '../../../../../common/detection_engine/schemas/resp
import { ImportRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/import_rules_schema';
import { CreateRulesBulkSchema } from '../../../../../common/detection_engine/schemas/request/create_rules_bulk_schema';
import { PartialAlert, FindResult } from '../../../../../../alerting/server';
import { ActionsClient } from '../../../../../../actions/server';
import { ActionsClient, FindActionResult } from '../../../../../../actions/server';
import { INTERNAL_IDENTIFIER } from '../../../../../common/constants';
import { RuleAlertType, isAlertType } from '../../rules/types';
import { createBulkErrorObject, BulkError, OutputError } from '../utils';
@ -305,7 +305,32 @@ export const getInvalidConnectors = async (
rules: PromiseFromStreams[],
actionsClient: ActionsClient
): Promise<[BulkError[], PromiseFromStreams[]]> => {
const actionsFind = await actionsClient.getAll();
let actionsFind: FindActionResult[] = [];
const reducerAccumulator = {
errors: new Map<string, BulkError>(),
rulesAcc: new Map<string, PromiseFromStreams>(),
};
try {
actionsFind = await actionsClient.getAll();
} catch (exc) {
if (exc?.output?.statusCode === 403) {
reducerAccumulator.errors.set(
uuid.v4(),
createBulkErrorObject({
statusCode: exc.output.statusCode,
message: `You may not have actions privileges required to import rules with actions: ${exc.output.payload.message}`,
})
);
} else {
reducerAccumulator.errors.set(
uuid.v4(),
createBulkErrorObject({
statusCode: 404,
message: JSON.stringify(exc),
})
);
}
}
const actionIds = new Set(actionsFind.map((action) => action.id));
const { errors, rulesAcc } = rules.reduce(
(acc, parsedRule) => {
@ -339,10 +364,7 @@ export const getInvalidConnectors = async (
}
return acc;
}, // using map (preserves ordering)
{
errors: new Map<string, BulkError>(),
rulesAcc: new Map<string, PromiseFromStreams>(),
}
reducerAccumulator
);
return [Array.from(errors.values()), Array.from(rulesAcc.values())];

View file

@ -24,11 +24,7 @@
"privileges": ["read", "write"]
},
{
"names": [
"metrics-endpoint.metadata_current_*",
".fleet-agents*",
".fleet-actions*"
],
"names": ["metrics-endpoint.metadata_current_*", ".fleet-agents*", ".fleet-actions*"],
"privileges": ["read"]
}
]

View file

@ -0,0 +1,11 @@
This user can CRUD rules and signals. The main difference here is the user has
```json
"builtInAlerts": ["all"],
```
privileges whereas the T1 and T2 have "read" privileges which prevents them from creating rules
| Role | Data Sources | Security Solution ML Jobs/Results | Lists | Rules/Exceptions | Action Connectors | Signals/Alerts |
| :-----------------: | :----------: | :------------------: | :---: | :--------------: | :---------------: | :------------: |
| Hunter / T3 Analyst | read, write | read | read | read, write | none | read, write |

View file

@ -0,0 +1,11 @@
#
# 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.
#
curl -v -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XDELETE ${ELASTICSEARCH_URL}/_security/user/hunter_no_actions

View file

@ -0,0 +1,43 @@
{
"elasticsearch": {
"cluster": [],
"indices": [
{
"names": [
"apm-*-transaction*",
"traces-apm*",
"auditbeat-*",
"endgame-*",
"filebeat-*",
"logs-*",
"packetbeat-*",
"winlogbeat-*"
],
"privileges": ["read", "write"]
},
{
"names": [".alerts-security*", ".siem-signals-*"],
"privileges": ["read", "write"]
},
{
"names": [".lists*", ".items*"],
"privileges": ["read", "write"]
},
{
"names": ["metrics-endpoint.metadata_current_*", ".fleet-agents*", ".fleet-actions*"],
"privileges": ["read"]
}
]
},
"kibana": [
{
"feature": {
"ml": ["read"],
"siem": ["all", "read_alerts", "crud_alerts"],
"securitySolutionCases": ["all"],
"builtInAlerts": ["all"]
},
"spaces": ["*"]
}
]
}

View file

@ -0,0 +1,6 @@
{
"password": "changeme",
"roles": ["hunter_no_actions"],
"full_name": "Hunter No Actions",
"email": "detections-reader@example.com"
}

View file

@ -0,0 +1,11 @@
#
# 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.
#
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XGET ${KIBANA_URL}/api/security/role/hunter_no_actions | jq -S .

View file

@ -0,0 +1,10 @@
/*
* 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 * as hunterNoActionsUser from './detections_user.json';
import * as hunterNoActionsRole from './detections_role.json';
export { hunterNoActionsUser, hunterNoActionsRole };

View file

@ -0,0 +1,14 @@
#
# 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.
#
ROLE=(${@:-./detections_role.json})
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
-XPUT ${KIBANA_URL}/api/security/role/hunter_no_actions \
-d @${ROLE}

View file

@ -0,0 +1,14 @@
#
# 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.
#
USER=(${@:-./detections_user.json})
curl -H 'Content-Type: application/json' -H 'kbn-xsrf: 123'\
-u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \
${ELASTICSEARCH_URL}/_security/user/hunter_no_actions \
-d @${USER}

View file

@ -7,6 +7,7 @@
export * from './detections_admin';
export * from './hunter';
export * from './hunter_no_actions';
export * from './platform_engineer';
export * from './reader';
export * from './rule_author';

View file

@ -11,6 +11,7 @@ import {
t1AnalystUser,
t2AnalystUser,
hunterUser,
hunterNoActionsUser,
ruleAuthorUser,
socManagerUser,
platformEngineerUser,
@ -19,6 +20,7 @@ import {
t1AnalystRole,
t2AnalystRole,
hunterRole,
hunterNoActionsRole,
ruleAuthorRole,
socManagerRole,
platformEngineerRole,
@ -53,6 +55,13 @@ export const createUserAndRole = async (
return postRoleAndUser(ROLES.t2_analyst, t2AnalystRole, t2AnalystUser, getService);
case ROLES.hunter:
return postRoleAndUser(ROLES.hunter, hunterRole, hunterUser, getService);
case ROLES.hunter_no_actions:
return postRoleAndUser(
ROLES.hunter_no_actions,
hunterNoActionsRole,
hunterNoActionsUser,
getService
);
case ROLES.rule_author:
return postRoleAndUser(ROLES.rule_author, ruleAuthorRole, ruleAuthorUser, getService);
case ROLES.soc_manager:
@ -105,7 +114,7 @@ interface RoleInterface {
feature: {
ml: string[];
siem: string[];
actions: string[];
actions?: string[];
builtInAlerts: string[];
};
spaces: string[];

View file

@ -28,6 +28,8 @@ import {
getImportExceptionsListSchemaMock,
} from '../../../../plugins/lists/common/schemas/request/import_exceptions_schema.mock';
import { deleteAllExceptions } from '../../../lists_api_integration/utils';
import { createUserAndRole, deleteUserAndRole } from '../../../common/services/security_solution';
import { ROLES } from '../../../../plugins/security_solution/common/test';
const getImportRuleBuffer = (connectorId: string) => {
const rule1 = {
@ -95,8 +97,127 @@ export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const log = getService('log');
const esArchiver = getService('esArchiver');
const supertestWithoutAuth = getService('supertestWithoutAuth');
describe('import_rules', () => {
describe('importing rules with different roles', () => {
before(async () => {
await createUserAndRole(getService, ROLES.hunter_no_actions);
await createUserAndRole(getService, ROLES.hunter);
});
after(async () => {
await deleteUserAndRole(getService, ROLES.hunter_no_actions);
await deleteUserAndRole(getService, ROLES.hunter);
});
beforeEach(async () => {
await createSignalsIndex(supertest, log);
});
afterEach(async () => {
await deleteSignalsIndex(supertest, log);
await deleteAllAlerts(supertest, log);
});
it('should successfully import rules without actions when user has no actions privileges', async () => {
const { body } = await supertestWithoutAuth
.post(`${DETECTION_ENGINE_RULES_URL}/_import`)
.auth(ROLES.hunter_no_actions, 'changeme')
.set('kbn-xsrf', 'true')
.attach('file', getSimpleRuleAsNdjson(['rule-1']), 'rules.ndjson')
.expect(200);
expect(body).to.eql({
errors: [],
success: true,
success_count: 1,
exceptions_errors: [],
exceptions_success: true,
exceptions_success_count: 0,
});
});
it('should successfully import rules with actions when user has "read" actions privileges', async () => {
// create a new action
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
const simpleRule: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [
{
group: 'default',
id: hookAction.id,
action_type_id: hookAction.actionTypeId,
params: {},
},
],
};
const { body } = await supertestWithoutAuth
.post(`${DETECTION_ENGINE_RULES_URL}/_import`)
.auth(ROLES.hunter, 'changeme')
.set('kbn-xsrf', 'true')
.attach('file', ruleToNdjson(simpleRule), 'rules.ndjson')
.expect(200);
expect(body).to.eql({
errors: [],
success: true,
success_count: 1,
exceptions_errors: [],
exceptions_success: true,
exceptions_success_count: 0,
});
});
it('should not import rules with actions when a user has no actions privileges', async () => {
// create a new action
const { body: hookAction } = await supertest
.post('/api/actions/action')
.set('kbn-xsrf', 'true')
.send(getWebHookAction())
.expect(200);
const simpleRule: ReturnType<typeof getSimpleRule> = {
...getSimpleRule('rule-1'),
actions: [
{
group: 'default',
id: hookAction.id,
action_type_id: hookAction.actionTypeId,
params: {},
},
],
};
const { body } = await supertestWithoutAuth
.post(`${DETECTION_ENGINE_RULES_URL}/_import`)
.auth(ROLES.hunter_no_actions, 'changeme')
.set('kbn-xsrf', 'true')
.attach('file', ruleToNdjson(simpleRule), 'rules.ndjson')
.expect(200);
expect(body).to.eql({
success: false,
success_count: 0,
errors: [
{
error: {
message:
'You may not have actions privileges required to import rules with actions: Unauthorized to get actions',
status_code: 403,
},
rule_id: '(unknown id)',
},
{
error: {
message: `1 connector is missing. Connector id missing is: ${hookAction.id}`,
status_code: 404,
},
rule_id: 'rule-1',
},
],
exceptions_errors: [],
exceptions_success: true,
exceptions_success_count: 0,
});
});
});
describe('importing rules with an index', () => {
beforeEach(async () => {
await createSignalsIndex(supertest, log);