[RAC][Timeline] - Add audit log to RBAC wrapped search strategy (#112040)

### Summary

Went back to add audit logging to the alerts table search strategy used to query RAC alerts. This PR also includes tests for the logging.
This commit is contained in:
Yara Tercero 2021-11-03 13:34:01 -07:00 committed by GitHub
parent bd97d1f001
commit c2d7f3355d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 181 additions and 20 deletions

View file

@ -144,6 +144,10 @@ Refer to the corresponding {es} logs for potential write errors.
| `unknown` | User is updating a space.
| `failure` | User is not authorized to update a space.
.2+| `alert_update`
| `unknown` | User is updating an alert.
| `failure` | User is not authorized to update an alert.
3+a|
====== Type: deletion
@ -214,6 +218,14 @@ Refer to the corresponding {es} logs for potential write errors.
| `success` | User has accessed a space as part of a search operation.
| `failure` | User is not authorized to search for spaces.
.2+| `alert_get`
| `success` | User has accessed an alert.
| `failure` | User is not authorized to access an alert.
.2+| `alert_find`
| `success` | User has accessed an alert as part of a search operation.
| `failure` | User is not authorized to access alerts.
3+a|
===== Category: web

View file

@ -24,6 +24,7 @@ export type {
export * from './config';
export * from './rule_data_plugin_service';
export * from './rule_data_client';
export * from './alert_data_client/audit_events';
export { createLifecycleRuleTypeFactory } from './utils/create_lifecycle_rule_type_factory';
export {

View file

@ -11,5 +11,5 @@
"server": true,
"ui": true,
"requiredPlugins": ["alerting", "cases", "data", "dataEnhanced", "kibanaReact", "kibanaUtils"],
"optionalPlugins": []
"optionalPlugins": ["security"]
}

View file

@ -18,11 +18,13 @@ import { defineRoutes } from './routes';
import { timelineSearchStrategyProvider } from './search_strategy/timeline';
import { timelineEqlSearchStrategyProvider } from './search_strategy/timeline/eql';
import { indexFieldsProvider } from './search_strategy/index_fields';
import { SecurityPluginSetup } from '../../security/server';
export class TimelinesPlugin
implements Plugin<TimelinesPluginUI, TimelinesPluginStart, SetupPlugins, StartPlugins>
{
private readonly logger: Logger;
private security?: SecurityPluginSetup;
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
@ -30,6 +32,8 @@ export class TimelinesPlugin
public setup(core: CoreSetup<StartPlugins, TimelinesPluginStart>, plugins: SetupPlugins) {
this.logger.debug('timelines: Setup');
this.security = plugins.security;
const router = core.http.createRouter();
// Register server side APIs
@ -39,7 +43,8 @@ export class TimelinesPlugin
core.getStartServices().then(([_, depsStart]) => {
const TimelineSearchStrategy = timelineSearchStrategyProvider(
depsStart.data,
depsStart.alerting
depsStart.alerting,
this.security
);
const TimelineEqlSearchStrategy = timelineEqlSearchStrategyProvider(depsStart.data);
const IndexFields = indexFieldsProvider();

View file

@ -32,16 +32,20 @@ import {
ENHANCED_ES_SEARCH_STRATEGY,
ISearchOptions,
} from '../../../../../../src/plugins/data/common';
import { AuditLogger, SecurityPluginSetup } from '../../../../security/server';
import { AlertAuditAction, alertAuditEvent } from '../../../../rule_registry/server';
export const timelineSearchStrategyProvider = <T extends TimelineFactoryQueryTypes>(
data: PluginStart,
alerting: AlertingPluginStartContract
alerting: AlertingPluginStartContract,
security?: SecurityPluginSetup
): ISearchStrategy<TimelineStrategyRequestType<T>, TimelineStrategyResponseType<T>> => {
const esAsInternal = data.search.searchAsInternalUser;
const es = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY);
return {
search: (request, options, deps) => {
const securityAuditLogger = security?.audit.asScoped(deps.request);
const factoryQueryType = request.factoryQueryType;
const entityType = request.entityType;
@ -59,6 +63,7 @@ export const timelineSearchStrategyProvider = <T extends TimelineFactoryQueryTyp
deps,
queryFactory,
alerting,
auditLogger: securityAuditLogger,
});
} else {
return timelineSearchStrategy({ es, request, options, deps, queryFactory });
@ -104,6 +109,7 @@ const timelineAlertsSearchStrategy = <T extends TimelineFactoryQueryTypes>({
deps,
queryFactory,
alerting,
auditLogger,
}: {
es: ISearchStrategy;
request: TimelineStrategyRequestType<T>;
@ -111,9 +117,8 @@ const timelineAlertsSearchStrategy = <T extends TimelineFactoryQueryTypes>({
deps: SearchStrategyDependencies;
alerting: AlertingPluginStartContract;
queryFactory: TimelineFactory<T>;
auditLogger: AuditLogger | undefined;
}) => {
// Based on what solution alerts you want to see, figures out what corresponding
// index to query (ex: siem --> .alerts-security.alerts)
const indices = request.defaultIndex ?? request.indexType;
const requestWithAlertsIndices = { ...request, defaultIndex: indices, indexName: indices };
@ -133,17 +138,46 @@ const timelineAlertsSearchStrategy = <T extends TimelineFactoryQueryTypes>({
return from(getAuthFilter()).pipe(
mergeMap(({ filter }) => {
const dsl = queryFactory.buildDsl({ ...requestWithAlertsIndices, authFilter: filter });
const dsl = queryFactory.buildDsl({
...requestWithAlertsIndices,
authFilter: filter,
});
return es.search({ ...requestWithAlertsIndices, params: dsl }, options, deps);
}),
map((response) => {
const rawResponse = shimHitsTotal(response.rawResponse, options);
// Do we have to loop over each hit? Yes.
// ecs auditLogger requires that we log each alert independently
if (auditLogger != null) {
rawResponse.hits?.hits?.forEach((hit) => {
auditLogger.log(
alertAuditEvent({
action: AlertAuditAction.FIND,
id: hit._id,
outcome: 'success',
})
);
});
}
return {
...response,
rawResponse: shimHitsTotal(response.rawResponse, options),
rawResponse,
};
}),
mergeMap((esSearchRes) => queryFactory.parse(requestWithAlertsIndices, esSearchRes)),
catchError((err) => {
// check if auth error, if yes, write to ecs logger
if (auditLogger != null && err?.output?.statusCode === 403) {
auditLogger.log(
alertAuditEvent({
action: AlertAuditAction.FIND,
outcome: 'failure',
error: err,
})
);
}
throw err;
})
);

View file

@ -8,6 +8,7 @@
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { DataPluginSetup, DataPluginStart } from '../../../../src/plugins/data/server/plugin';
import { PluginStartContract as AlertingPluginStartContract } from '../../alerting/server';
import { SecurityPluginSetup } from '../../security/server';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface TimelinesPluginUI {}
@ -16,6 +17,7 @@ export interface TimelinesPluginStart {}
export interface SetupPlugins {
data: DataPluginSetup;
security?: SecurityPluginSetup;
}
export interface StartPlugins {

View file

@ -71,6 +71,10 @@ const onlyNotInCoverageTests = [
require.resolve('../test/saved_object_api_integration/security_and_spaces/config_trial.ts'),
require.resolve('../test/saved_object_api_integration/security_and_spaces/config_basic.ts'),
require.resolve('../test/saved_object_api_integration/spaces_only/config.ts'),
// TODO: Enable once RBAC timeline search strategy
// tests updated
// require.resolve('../test/timeline/security_and_spaces/config_basic.ts'),
require.resolve('../test/timeline/security_and_spaces/config_trial.ts'),
require.resolve('../test/ui_capabilities/security_and_spaces/config.ts'),
require.resolve('../test/ui_capabilities/spaces_only/config.ts'),
require.resolve('../test/upgrade_assistant_integration/config.js'),

View file

@ -7,6 +7,7 @@
import { CA_CERT_PATH } from '@kbn/dev-utils';
import { FtrConfigProviderContext } from '@kbn/test';
import { resolve } from 'path';
import { services } from './services';
import { getAllExternalServiceSimulatorPaths } from '../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin';
@ -40,6 +41,7 @@ const enabledActionTypes = [
export function createTestConfig(name: string, options: CreateTestConfigOptions) {
const { license = 'trial', disabledPlugins = [], ssl = false, testFiles = [] } = options;
const auditLogPath = resolve(__dirname, './audit.log');
return async ({ readConfigFile }: FtrConfigProviderContext) => {
const xPackApiIntegrationTestsConfig = await readConfigFile(
@ -85,6 +87,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
// TO DO: Remove feature flags once we're good to go
'--xpack.securitySolution.enableExperimental=["ruleRegistryEnabled"]',
'--xpack.ruleRegistry.write.enabled=true',
'--xpack.security.audit.enabled=true',
'--xpack.security.audit.appender.type=file',
`--xpack.security.audit.appender.fileName=${auditLogPath}`,
'--xpack.security.audit.appender.layout.type=json',
`--server.xsrf.allowlist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`,
...(ssl
? [

View file

@ -5,9 +5,11 @@
* 2.0.
*/
import Path from 'path';
import Fs from 'fs';
import { JsonObject } from '@kbn/utility-types';
import expect from '@kbn/expect';
import { ALERT_UUID, ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils';
import { ALERT_RULE_CONSUMER } from '@kbn/rule-data-utils';
import { User } from '../../../../rule_registry/common/lib/authentication/types';
import { TimelineEdges, TimelineNonEcsData } from '../../../../../plugins/timelines/common/';
@ -18,6 +20,7 @@ import {
obsMinReadAlertsReadSpacesAll,
obsMinRead,
obsMinReadSpacesAll,
superUser,
} from '../../../../rule_registry/common/lib/authentication/users';
import {
Direction,
@ -25,6 +28,28 @@ import {
} from '../../../../../plugins/security_solution/common/search_strategy';
import { FtrProviderContext } from '../../../common/ftr_provider_context';
class FileWrapper {
constructor(private readonly path: string) {}
async reset() {
// "touch" each file to ensure it exists and is empty before each test
await Fs.promises.writeFile(this.path, '');
}
async read() {
const content = await Fs.promises.readFile(this.path, { encoding: 'utf8' });
return content.trim().split('\n');
}
async readJSON() {
const content = await this.read();
return content.map((l) => JSON.parse(l));
}
// writing in a file is an async operation. we use this method to make sure logs have been written.
async isNotEmpty() {
const content = await this.read();
const line = content[0];
return line.length > 0;
}
}
interface TestCase {
/** The space where the alert exists */
space?: string;
@ -44,6 +69,7 @@ const TO = '3000-01-01T00:00:00.000Z';
const FROM = '2000-01-01T00:00:00.000Z';
const TEST_URL = '/internal/search/timelineSearchStrategy/';
const SPACE_1 = 'space1';
const SPACE_2 = 'space2';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
@ -56,18 +82,9 @@ export default ({ getService }: FtrProviderContext) => {
{
field: '@timestamp',
},
{
field: ALERT_RULE_CONSUMER,
},
{
field: ALERT_UUID,
},
{
field: 'event.kind',
},
],
factoryQueryType: TimelineEventsQueries.all,
fieldRequested: ['@timestamp', 'message', ALERT_RULE_CONSUMER, ALERT_UUID, 'event.kind'],
fieldRequested: ['@timestamp'],
fields: [],
filterQuery: {
bool: {
@ -98,6 +115,10 @@ export default ({ getService }: FtrProviderContext) => {
});
describe('Timeline - Events', () => {
const logFilePath = Path.resolve(__dirname, '../../../common/audit.log');
const logFile = new FileWrapper(logFilePath);
const retry = getService('retry');
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/rule_registry/alerts');
});
@ -162,14 +183,15 @@ export default ({ getService }: FtrProviderContext) => {
});
}
describe('alerts authentication', () => {
// TODO - tests need to be updated with new table logic
describe.skip('alerts authentication', () => {
addTests({
space: SPACE_1,
featureIds: ['apm'],
expectedNumberAlerts: 2,
body: {
...getPostBody(),
defaultIndex: ['.alerts-*'],
defaultIndex: ['.alerts*'],
entityType: 'alerts',
alertConsumers: ['apm'],
},
@ -177,5 +199,80 @@ export default ({ getService }: FtrProviderContext) => {
unauthorizedUsers: [obsMinRead, obsMinReadSpacesAll],
});
});
describe('logging', () => {
beforeEach(async () => {
await logFile.reset();
});
afterEach(async () => {
await logFile.reset();
});
it('logs success events when reading alerts', async () => {
await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(SPACE_1)}${TEST_URL}`)
.auth(superUser.username, superUser.password)
.set('kbn-xsrf', 'true')
.set('Content-Type', 'application/json')
.send({
...getPostBody(),
defaultIndex: ['.alerts-*'],
entityType: 'alerts',
alertConsumers: ['apm'],
})
.expect(200);
await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty());
const content = await logFile.readJSON();
const httpEvent = content.find((c) => c.event.action === 'http_request');
expect(httpEvent).to.be.ok();
expect(httpEvent.trace.id).to.be.ok();
expect(httpEvent.user.name).to.be(superUser.username);
expect(httpEvent.kibana.space_id).to.be('space1');
expect(httpEvent.http.request.method).to.be('post');
expect(httpEvent.url.path).to.be('/s/space1/internal/search/timelineSearchStrategy/');
const findEvents = content.filter((c) => c.event.action === 'alert_find');
expect(findEvents[0].trace.id).to.be.ok();
expect(findEvents[0].event.outcome).to.be('success');
expect(findEvents[0].user.name).to.be(superUser.username);
expect(findEvents[0].kibana.space_id).to.be('space1');
});
it('logs failure events when unauthorized to read alerts', async () => {
await supertestWithoutAuth
.post(`${getSpaceUrlPrefix(SPACE_2)}${TEST_URL}`)
.auth(obsMinRead.username, obsMinRead.password)
.set('kbn-xsrf', 'true')
.set('Content-Type', 'application/json')
.send({
...getPostBody(),
defaultIndex: ['.alerts-*'],
entityType: 'alerts',
alertConsumers: ['apm'],
})
.expect(500);
await retry.waitFor('logs event in the dest file', async () => await logFile.isNotEmpty());
const content = await logFile.readJSON();
const httpEvent = content.find((c) => c.event.action === 'http_request');
expect(httpEvent).to.be.ok();
expect(httpEvent.trace.id).to.be.ok();
expect(httpEvent.user.name).to.be(obsMinRead.username);
expect(httpEvent.kibana.space_id).to.be(SPACE_2);
expect(httpEvent.http.request.method).to.be('post');
expect(httpEvent.url.path).to.be('/s/space2/internal/search/timelineSearchStrategy/');
const findEvents = content.filter((c) => c.event.action === 'alert_find');
expect(findEvents.length).to.equal(1);
expect(findEvents[0].trace.id).to.be.ok();
expect(findEvents[0].event.outcome).to.be('failure');
expect(findEvents[0].user.name).to.be(obsMinRead.username);
expect(findEvents[0].kibana.space_id).to.be(SPACE_2);
});
});
});
};