[Security Assistant] Adds audit logging to knowledge base entry changes (#203349)

This commit is contained in:
Steph Milovic 2024-12-11 11:55:08 -07:00 committed by GitHub
parent b9bac1628b
commit 84a2d40953
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 366 additions and 10 deletions

View file

@ -151,6 +151,18 @@ Refer to the corresponding {es} logs for potential write errors.
.1+| `product_documentation_create`
| `unknown` | User requested to install the product documentation for use in AI Assistants.
.2+| `knowledge_base_entry_create`
| `success` | User has created knowledge base entry [id=x]
| `failure` | Failed attempt to create a knowledge base entry
.2+| `knowledge_base_entry_update`
| `success` | User has updated knowledge base entry [id=x]
| `failure` | Failed attempt to update a knowledge base entry
.2+| `knowledge_base_entry_delete`
| `success` | User has deleted knowledge base entry [id=x]
| `failure` | Failed attempt to delete a knowledge base entry
3+a|
====== Type: change

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server';
import { AuditLogger, AuthenticatedUser, ElasticsearchClient, Logger } from '@kbn/core/server';
import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server';
import { ESSearchRequest, ESSearchResponse } from '@kbn/es-types';
@ -19,6 +19,7 @@ export interface AIAssistantDataClientParams {
elasticsearchClientPromise: Promise<ElasticsearchClient>;
kibanaVersion: string;
spaceId: string;
auditLogger?: AuditLogger;
logger: Logger;
indexPatternsResourceName: string;
currentUser: AuthenticatedUser | null;

View file

@ -0,0 +1,181 @@
/*
* 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 {
knowledgeBaseAuditEvent,
KnowledgeBaseAuditAction,
AUDIT_OUTCOME,
AUDIT_CATEGORY,
AUDIT_TYPE,
} from './audit_events';
describe('knowledgeBaseAuditEvent', () => {
it('should generate a success event with id', () => {
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
id: '123',
outcome: AUDIT_OUTCOME.SUCCESS,
});
expect(event).toEqual({
message: 'User has created knowledge base entry [id=123]',
event: {
action: KnowledgeBaseAuditAction.CREATE,
category: [AUDIT_CATEGORY.DATABASE],
type: [AUDIT_TYPE.CREATION],
outcome: AUDIT_OUTCOME.SUCCESS,
},
});
});
it('should generate a success event with name', () => {
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
name: 'My document',
outcome: AUDIT_OUTCOME.SUCCESS,
});
expect(event).toEqual({
message: 'User has created knowledge base entry [name="My document"]',
event: {
action: KnowledgeBaseAuditAction.CREATE,
category: [AUDIT_CATEGORY.DATABASE],
type: [AUDIT_TYPE.CREATION],
outcome: AUDIT_OUTCOME.SUCCESS,
},
});
});
it('should generate a success event with name and id', () => {
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
name: 'My document',
id: '123',
outcome: AUDIT_OUTCOME.SUCCESS,
});
expect(event).toEqual({
message: 'User has created knowledge base entry [id=123, name="My document"]',
event: {
action: KnowledgeBaseAuditAction.CREATE,
category: [AUDIT_CATEGORY.DATABASE],
type: [AUDIT_TYPE.CREATION],
outcome: AUDIT_OUTCOME.SUCCESS,
},
});
});
it('should generate a success event without id or name', () => {
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
outcome: AUDIT_OUTCOME.SUCCESS,
});
expect(event).toEqual({
message: 'User has created a knowledge base entry',
event: {
action: KnowledgeBaseAuditAction.CREATE,
category: [AUDIT_CATEGORY.DATABASE],
type: [AUDIT_TYPE.CREATION],
outcome: AUDIT_OUTCOME.SUCCESS,
},
});
});
it('should generate a failure event with an error', () => {
const error = new Error('Test error');
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
id: '456',
error,
});
expect(event).toEqual({
message: 'Failed attempt to create knowledge base entry [id=456]',
event: {
action: KnowledgeBaseAuditAction.CREATE,
category: [AUDIT_CATEGORY.DATABASE],
type: [AUDIT_TYPE.CREATION],
outcome: AUDIT_OUTCOME.FAILURE,
},
error: {
code: error.name,
message: error.message,
},
});
});
it('should handle unknown outcome', () => {
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
id: '789',
outcome: AUDIT_OUTCOME.UNKNOWN,
});
expect(event).toEqual({
message: 'User is creating knowledge base entry [id=789]',
event: {
action: KnowledgeBaseAuditAction.CREATE,
category: [AUDIT_CATEGORY.DATABASE],
type: [AUDIT_TYPE.CREATION],
outcome: AUDIT_OUTCOME.UNKNOWN,
},
});
});
it('should handle update action', () => {
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.UPDATE,
id: '123',
outcome: AUDIT_OUTCOME.SUCCESS,
});
expect(event).toEqual({
message: 'User has updated knowledge base entry [id=123]',
event: {
action: KnowledgeBaseAuditAction.UPDATE,
category: [AUDIT_CATEGORY.DATABASE],
type: [AUDIT_TYPE.CHANGE],
outcome: AUDIT_OUTCOME.SUCCESS,
},
});
});
it('should handle delete action', () => {
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.DELETE,
id: '123',
});
expect(event).toEqual({
message: 'User has deleted knowledge base entry [id=123]',
event: {
action: KnowledgeBaseAuditAction.DELETE,
category: [AUDIT_CATEGORY.DATABASE],
type: [AUDIT_TYPE.DELETION],
outcome: AUDIT_OUTCOME.SUCCESS,
},
});
});
it('should default to success if outcome is not provided and no error exists', () => {
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
});
expect(event.event?.outcome).toBe(AUDIT_OUTCOME.SUCCESS);
});
it('should prioritize error outcome over provided outcome', () => {
const error = new Error('Error with priority');
const event = knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
outcome: AUDIT_OUTCOME.SUCCESS,
error,
});
expect(event.event?.outcome).toBe(AUDIT_OUTCOME.FAILURE);
});
});

View file

@ -0,0 +1,94 @@
/*
* 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 { EcsEvent } from '@kbn/core/server';
import { AuditEvent } from '@kbn/security-plugin/server';
import { ArrayElement } from '@kbn/utility-types';
export enum AUDIT_TYPE {
CHANGE = 'change',
DELETION = 'deletion',
ACCESS = 'access',
CREATION = 'creation',
}
export enum AUDIT_CATEGORY {
AUTHENTICATION = 'authentication',
DATABASE = 'database',
WEB = 'web',
}
export enum AUDIT_OUTCOME {
FAILURE = 'failure',
SUCCESS = 'success',
UNKNOWN = 'unknown',
}
export enum KnowledgeBaseAuditAction {
CREATE = 'knowledge_base_entry_create',
UPDATE = 'knowledge_base_entry_update',
DELETE = 'knowledge_base_entry_delete',
}
type VerbsTuple = [string, string, string];
const knowledgeBaseEventVerbs: Record<KnowledgeBaseAuditAction, VerbsTuple> = {
knowledge_base_entry_create: ['create', 'creating', 'created'],
knowledge_base_entry_update: ['update', 'updating', 'updated'],
knowledge_base_entry_delete: ['delete', 'deleting', 'deleted'],
};
const knowledgeBaseEventTypes: Record<KnowledgeBaseAuditAction, ArrayElement<EcsEvent['type']>> = {
knowledge_base_entry_create: AUDIT_TYPE.CREATION,
knowledge_base_entry_update: AUDIT_TYPE.CHANGE,
knowledge_base_entry_delete: AUDIT_TYPE.DELETION,
};
export interface KnowledgeBaseAuditEventParams {
action: KnowledgeBaseAuditAction;
error?: Error;
id?: string;
name?: string;
outcome?: EcsEvent['outcome'];
}
export function knowledgeBaseAuditEvent({
action,
error,
id,
name,
outcome,
}: KnowledgeBaseAuditEventParams): AuditEvent {
let doc = 'a knowledge base entry';
if (id && name) {
doc = `knowledge base entry [id=${id}, name="${name}"]`;
} else if (id) {
doc = `knowledge base entry [id=${id}]`;
} else if (name) {
doc = `knowledge base entry [name="${name}"]`;
}
const [present, progressive, past] = knowledgeBaseEventVerbs[action];
const message = error
? `Failed attempt to ${present} ${doc}`
: outcome === 'unknown'
? `User is ${progressive} ${doc}`
: `User has ${past} ${doc}`;
const type = knowledgeBaseEventTypes[action];
return {
message,
event: {
action,
category: [AUDIT_CATEGORY.DATABASE],
type: type ? [type] : undefined,
outcome: error ? AUDIT_OUTCOME.FAILURE : outcome ?? AUDIT_OUTCOME.SUCCESS,
},
error: error && {
code: error.name,
message: error.message,
},
};
}

View file

@ -8,6 +8,7 @@
import { v4 as uuidv4 } from 'uuid';
import {
AnalyticsServiceSetup,
type AuditLogger,
AuthenticatedUser,
ElasticsearchClient,
Logger,
@ -18,6 +19,7 @@ import {
KnowledgeBaseEntryResponse,
KnowledgeBaseEntryUpdateProps,
} from '@kbn/elastic-assistant-common';
import { AUDIT_OUTCOME, KnowledgeBaseAuditAction, knowledgeBaseAuditEvent } from './audit_events';
import {
CREATE_KNOWLEDGE_BASE_ENTRY_ERROR_EVENT,
CREATE_KNOWLEDGE_BASE_ENTRY_SUCCESS_EVENT,
@ -26,6 +28,7 @@ import { getKnowledgeBaseEntry } from './get_knowledge_base_entry';
import { CreateKnowledgeBaseEntrySchema, UpdateKnowledgeBaseEntrySchema } from './types';
export interface CreateKnowledgeBaseEntryParams {
auditLogger?: AuditLogger;
esClient: ElasticsearchClient;
knowledgeBaseIndex: string;
logger: Logger;
@ -37,6 +40,7 @@ export interface CreateKnowledgeBaseEntryParams {
}
export const createKnowledgeBaseEntry = async ({
auditLogger,
esClient,
knowledgeBaseIndex,
spaceId,
@ -75,13 +79,27 @@ export const createKnowledgeBaseEntry = async ({
logger,
user,
});
auditLogger?.log(
knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
id: newKnowledgeBaseEntry?.id,
name: newKnowledgeBaseEntry?.name,
outcome: AUDIT_OUTCOME.SUCCESS,
})
);
telemetry.reportEvent(CREATE_KNOWLEDGE_BASE_ENTRY_SUCCESS_EVENT.eventType, telemetryPayload);
return newKnowledgeBaseEntry;
} catch (err) {
logger.error(
`Error creating Knowledge Base Entry: ${err} with kbResource: ${knowledgeBaseEntry.name}`
);
auditLogger?.log(
knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
outcome: AUDIT_OUTCOME.FAILURE,
error: err,
})
);
telemetry.reportEvent(CREATE_KNOWLEDGE_BASE_ENTRY_ERROR_EVENT.eventType, {
...telemetryPayload,
errorMessage: err.message ?? 'Unknown error',

View file

@ -25,7 +25,7 @@ import {
import pRetry from 'p-retry';
import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { StructuredTool } from '@langchain/core/tools';
import { AnalyticsServiceSetup, ElasticsearchClient } from '@kbn/core/server';
import { AnalyticsServiceSetup, AuditLogger, ElasticsearchClient } from '@kbn/core/server';
import { IndexPatternsFetcher } from '@kbn/data-views-plugin/server';
import { map } from 'lodash';
import { AIAssistantDataClient, AIAssistantDataClientParams } from '..';
@ -333,6 +333,8 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
}
};
// TODO make this function private
// no telemetry, no audit logs
/**
* Adds LangChain Documents to the knowledge base
*
@ -591,10 +593,12 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
* @param global
*/
public createKnowledgeBaseEntry = async ({
auditLogger,
knowledgeBaseEntry,
telemetry,
global = false,
}: {
auditLogger?: AuditLogger;
knowledgeBaseEntry: KnowledgeBaseEntryCreateProps;
global?: boolean;
telemetry: AnalyticsServiceSetup;
@ -617,6 +621,7 @@ export class AIAssistantKnowledgeBaseDataClient extends AIAssistantDataClient {
this.options.logger.debug(`kbIndex: ${this.indexTemplateAndPattern.alias}`);
const esClient = await this.options.elasticsearchClientPromise;
return createKnowledgeBaseEntry({
auditLogger,
esClient,
knowledgeBaseIndex: this.indexTemplateAndPattern.alias,
logger: this.options.logger,

View file

@ -6,7 +6,12 @@
*/
import moment from 'moment';
import { AnalyticsServiceSetup, IKibanaResponse, KibanaResponseFactory } from '@kbn/core/server';
import {
AnalyticsServiceSetup,
AuditLogger,
IKibanaResponse,
KibanaResponseFactory,
} from '@kbn/core/server';
import { transformError } from '@kbn/securitysolution-es-utils';
import {
@ -20,6 +25,11 @@ import {
} from '@kbn/elastic-assistant-common';
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
import {
AUDIT_OUTCOME,
KnowledgeBaseAuditAction,
knowledgeBaseAuditEvent,
} from '../../../ai_assistant_data_clients/knowledge_base/audit_events';
import { CREATE_KNOWLEDGE_BASE_ENTRY_SUCCESS_EVENT } from '../../../lib/telemetry/event_based_telemetry';
import { performChecks } from '../../helpers';
import { KNOWLEDGE_BASE_ENTRIES_TABLE_MAX_PAGE_SIZE } from '../../../../common/constants';
@ -62,7 +72,8 @@ const buildBulkResponse = (
deleted = [],
skipped = [],
}: KnowledgeBaseEntryBulkCrudActionResults & { errors: BulkOperationError[] },
telemetry: AnalyticsServiceSetup
telemetry: AnalyticsServiceSetup,
auditLogger?: AuditLogger
): IKibanaResponse<KnowledgeBaseEntryBulkCrudActionResponse> => {
const numSucceeded = updated.length + created.length + deleted.length;
const numSkipped = skipped.length;
@ -90,6 +101,39 @@ const buildBulkResponse = (
sharing: entry.users.length ? 'private' : 'global',
...(entry.type === 'document' ? { source: entry.source } : {}),
});
auditLogger?.log(
knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.CREATE,
id: entry.id,
name: entry.name,
outcome: AUDIT_OUTCOME.SUCCESS,
})
);
});
}
if (updated.length) {
updated.forEach((entry) => {
auditLogger?.log(
knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.UPDATE,
id: entry.id,
name: entry.name,
outcome: AUDIT_OUTCOME.SUCCESS,
})
);
});
}
if (deleted.length) {
deleted.forEach((deletedId) => {
auditLogger?.log(
knowledgeBaseAuditEvent({
action: KnowledgeBaseAuditAction.DELETE,
id: deletedId,
outcome: AUDIT_OUTCOME.SUCCESS,
})
);
});
}
if (numFailed > 0) {
@ -308,7 +352,8 @@ export const bulkActionKnowledgeBaseEntriesRoute = (router: ElasticAssistantPlug
skipped: [],
errors,
},
ctx.elasticAssistant.telemetry
ctx.elasticAssistant.telemetry,
ctx.elasticAssistant.auditLogger
);
} catch (err) {
const error = transformError(err);

View file

@ -63,6 +63,7 @@ export const createKnowledgeBaseEntryRoute = (router: ElasticAssistantPluginRout
const createResponse = await kbDataClient?.createKnowledgeBaseEntry({
knowledgeBaseEntry: request.body,
global: request.body.users != null && request.body.users.length === 0,
auditLogger: ctx.elasticAssistant.auditLogger,
telemetry: ctx.elasticAssistant.telemetry,
});

View file

@ -62,7 +62,7 @@ export class RequestContextFactory implements IRequestContextFactory {
core: coreContext,
actions: startPlugins.actions,
auditLogger: coreStart.security.audit?.asScoped(request),
logger: this.logger,
getServerBasePath: () => core.http.basePath.serverBasePath,
@ -94,7 +94,6 @@ export class RequestContextFactory implements IRequestContextFactory {
capabilityPath: 'securitySolutionAssistant.*',
}
);
return this.assistantService.createAIAssistantKnowledgeBaseDataClient({
spaceId: getSpaceId(),
logger: this.logger,

View file

@ -18,7 +18,7 @@ import type {
IRouter,
KibanaRequest,
Logger,
SecurityServiceStart,
AuditLogger,
} from '@kbn/core/server';
import type { LlmTasksPluginStart } from '@kbn/llm-tasks-plugin/server';
import { type MlPluginSetup } from '@kbn/ml-plugin/server';
@ -116,7 +116,6 @@ export interface ElasticAssistantPluginStartDependencies {
llmTasks: LlmTasksPluginStart;
inference: InferenceServerStart;
spaces?: SpacesPluginStart;
security: SecurityServiceStart;
licensing: LicensingPluginStart;
productDocBase: ProductDocBaseStartContract;
}
@ -124,6 +123,7 @@ export interface ElasticAssistantPluginStartDependencies {
export interface ElasticAssistantApiRequestHandlerContext {
core: CoreRequestHandlerContext;
actions: ActionsPluginStart;
auditLogger?: AuditLogger;
getRegisteredFeatures: GetRegisteredFeatures;
getRegisteredTools: GetRegisteredTools;
logger: Logger;