diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index ef12f4303c1b4..0c5bee6106f01 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -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 diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/index.ts index 706e4444488d9..f17ffac7d42ac 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/index.ts @@ -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; kibanaVersion: string; spaceId: string; + auditLogger?: AuditLogger; logger: Logger; indexPatternsResourceName: string; currentUser: AuthenticatedUser | null; diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/audit_events.test.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/audit_events.test.ts new file mode 100644 index 0000000000000..8d65d9e628285 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/audit_events.test.ts @@ -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); + }); +}); diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/audit_events.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/audit_events.ts new file mode 100644 index 0000000000000..584efd603f4b7 --- /dev/null +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/audit_events.ts @@ -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 = { + knowledge_base_entry_create: ['create', 'creating', 'created'], + knowledge_base_entry_update: ['update', 'updating', 'updated'], + knowledge_base_entry_delete: ['delete', 'deleting', 'deleted'], +}; + +const knowledgeBaseEventTypes: Record> = { + 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, + }, + }; +} diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts index 8e1d749c7f78b..cda1781c3a1e9 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/create_knowledge_base_entry.ts @@ -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', diff --git a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts index c3ce7fb1a43a0..0065066455475 100644 --- a/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts +++ b/x-pack/plugins/elastic_assistant/server/ai_assistant_data_clients/knowledge_base/index.ts @@ -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, diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts index 01e8c9ebf3f08..82c8b4e0148fd 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/bulk_actions_route.ts @@ -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 => { 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); diff --git a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts index 8a4004e4a236a..cd1216713e18a 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/knowledge_base/entries/create_route.ts @@ -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, }); diff --git a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts index 30045b3da8ad9..08ef1bb1828d6 100644 --- a/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts +++ b/x-pack/plugins/elastic_assistant/server/routes/request_context_factory.ts @@ -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, diff --git a/x-pack/plugins/elastic_assistant/server/types.ts b/x-pack/plugins/elastic_assistant/server/types.ts index 6158acde679ff..1102d4b7b8441 100755 --- a/x-pack/plugins/elastic_assistant/server/types.ts +++ b/x-pack/plugins/elastic_assistant/server/types.ts @@ -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;