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

### 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.

Co-authored-by: Yara Tercero <yctercero@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2021-11-03 19:19:15 -04:00 committed by GitHub
parent 9074bf3882
commit e6f52d9467
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);
});
});
});
};