Skip to main content
Glama
auditevent.ts12.7 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { ProfileResource } from '@medplum/core'; import { append, createReference, flatMapFilter, isResource, isResourceWithId, resolveId } from '@medplum/core'; import type { AuditEvent, AuditEventAgentNetwork, AuditEventEntity, Bot, Coding, Extension, Practitioner, Project, Reference, Resource, Subscription, } from '@medplum/fhirtypes'; import type { BotExecutionRequest } from '../bots/types'; import { getConfig } from '../config/loader'; import { AuthenticatedRequestContext, buildTracingExtension, tryGetRequestContext } from '../context'; import { getSystemRepo } from '../fhir/repo'; /* * This file includes a collection of utility functions for working with AuditEvents. * * AuditEvent is a versatile resource that can be used to record a variety of events. * However, for core logging, we want to use a consistent set of event types. * This file includes a collection of best practices derived from examples from the HL7 FHIR website. */ /** * DICOM code system. * See: https://dicom.nema.org/medical/dicom/current/output/chtml/part16/chapter_D.html */ export const DicomCodeSystem = 'http://dicom.nema.org/resources/ontology/DCM'; /** * AuditEvent type code system. * See: https://www.hl7.org/fhir/valueset-audit-event-type.html */ export const AuditEventTypeCodeSystem = 'http://terminology.hl7.org/CodeSystem/audit-event-type'; /** * RESTful operation code system. * See: https://hl7.org/fhir/codesystem-restful-interaction.html */ export const RestfulActionCodeSystem = 'http://hl7.org/fhir/restful-interaction'; /** * User authentication event type. * See examples: * Login: https://www.hl7.org/fhir/audit-event-example-login.json.html * Logout: https://www.hl7.org/fhir/audit-event-example-logout.json.html */ export const UserAuthenticationEvent: Coding = { system: DicomCodeSystem, code: '110114', display: 'User Authentication', }; /** * RESTful operation event type. * See examples: * vread: https://www.hl7.org/fhir/audit-event-example-vread.json.html */ export const RestfulOperationType: Coding = { system: 'http://terminology.hl7.org/CodeSystem/audit-event-type', code: 'rest', display: 'Restful Operation', }; /* * UserAuthentication subtypes. * See: https://dicom.nema.org/medical/dicom/current/output/chtml/part16/chapter_D.html */ export const LoginEvent = { system: DicomCodeSystem, code: '110122', display: 'Login' } as const; export const LogoutEvent = { system: DicomCodeSystem, code: '110123', display: 'Logout' } as const; /* * Restful interactions. * See: https://hl7.org/fhir/codesystem-restful-interaction.html */ export const RestfulInteractions = { read: { system: RestfulActionCodeSystem, code: 'read', display: 'read' }, }; export const ReadInteraction = { system: RestfulActionCodeSystem, code: 'read', display: 'read' } as const; export const VreadInteraction = { system: RestfulActionCodeSystem, code: 'vread', display: 'vread' } as const; export const UpdateInteraction = { system: RestfulActionCodeSystem, code: 'update', display: 'update' } as const; export const PatchInteraction = { system: RestfulActionCodeSystem, code: 'patch', display: 'patch' } as const; export const DeleteInteraction = { system: RestfulActionCodeSystem, code: 'delete', display: 'delete' } as const; export const HistoryInteraction = { system: RestfulActionCodeSystem, code: 'history', display: 'history' } as const; export const CreateInteraction = { system: RestfulActionCodeSystem, code: 'create', display: 'create' } as const; export const SearchInteraction = { system: RestfulActionCodeSystem, code: 'search', display: 'search' } as const; export const BatchInteraction = { system: RestfulActionCodeSystem, code: 'batch', display: 'batch' } as const; export const TransactionInteraction = { system: RestfulActionCodeSystem, code: 'transaction', display: 'transaction', } as const; export const OperationInteraction = { system: RestfulActionCodeSystem, code: 'operation', display: 'operation', } as const; /* eslint-disable @typescript-eslint/no-duplicate-type-constituents */ export type AuditEventType = typeof UserAuthenticationEvent | typeof RestfulOperationType; export type AuditEventSubtype = | typeof LoginEvent | typeof LogoutEvent | typeof ReadInteraction | typeof VreadInteraction | typeof UpdateInteraction | typeof PatchInteraction | typeof DeleteInteraction | typeof HistoryInteraction | typeof CreateInteraction | typeof SearchInteraction | typeof TransactionInteraction | typeof BatchInteraction | typeof OperationInteraction; /* eslint-enable @typescript-eslint/no-duplicate-type-constituents */ /** * AuditEvent action code. * See: https://www.hl7.org/fhir/valueset-audit-event-action.html */ export const AuditEventAction = { Create: 'C', Read: 'R', Update: 'U', Delete: 'D', Execute: 'E', } as const; export type AuditEventAction = (typeof AuditEventAction)[keyof typeof AuditEventAction]; const AuditEventActionLookup: Record<AuditEventSubtype['code'], AuditEventAction | undefined> = { create: 'C', read: 'R', vread: 'R', history: 'R', search: 'R', update: 'U', patch: 'U', delete: 'D', batch: undefined, transaction: undefined, operation: undefined, 110122: undefined, 110123: undefined, }; /** * AuditEvent outcome code. * See: https://www.hl7.org/fhir/valueset-audit-event-outcome.html */ export const AuditEventOutcome = { Success: '0', MinorFailure: '4', SeriousFailure: '8', MajorFailure: '12', } as const; export type AuditEventOutcome = (typeof AuditEventOutcome)[keyof typeof AuditEventOutcome]; export function createAuditEvent( type: AuditEventType, subtype: AuditEventSubtype, projectId: string, who: Reference | undefined, remoteAddress: string | undefined, outcome: AuditEventOutcome, options?: { description?: string; resource?: Resource | Reference; searchQuery?: string; durationMs?: number; } ): AuditEvent { const config = getConfig(); let entity: AuditEventEntity[] | undefined = undefined; if (options?.resource) { const what: Reference = isResource(options.resource) ? createReference(options.resource) : options.resource; entity = [{ what: applyOptionalRedaction(what) }]; } else if (options?.searchQuery) { entity = [{ query: options.searchQuery }]; } let network: AuditEventAgentNetwork | undefined = undefined; if (remoteAddress) { network = { address: remoteAddress, type: '2' }; } let extension = buildTracingExtension(); if (options?.durationMs) { extension = append(extension, buildDurationExtension(options.durationMs)); } const auditEvent: AuditEvent = { resourceType: 'AuditEvent', meta: { project: projectId }, type, subtype: [subtype], action: AuditEventActionLookup[subtype.code], recorded: new Date().toISOString(), source: { observer: { identifier: { value: config.baseUrl } } }, agent: [ { who: applyOptionalRedaction(who) as Reference<Practitioner>, requestor: true, network, }, ], outcome, outcomeDesc: options?.description, entity, extension, }; return auditEvent; } export function logAuditEvent(auditEvent: AuditEvent): void { const config = getConfig(); if (config.logAuditEvents) { console.log(JSON.stringify(auditEvent)); } } function buildDurationExtension(duration: number): Extension { return { url: 'https://medplum.com/fhir/StructureDefinition/durationMs', valueInteger: Math.round(duration), }; } export function applyOptionalRedaction(ref: Reference | undefined): Reference | undefined { const config = getConfig(); const ctx = tryGetRequestContext(); let project: Project | undefined; if (ctx instanceof AuthenticatedRequestContext) { project = ctx.project; } if (config.redactAuditEvents || project?.setting?.find((s) => s.name === 'redactAuditEvents')?.valueBoolean) { return { ...ref, display: undefined }; } else { return ref; } } const defaultBotOutputLength = 10 * 1024; // 10 KiB /** * Creates an AuditEvent for a Bot execution. * @param request - The bot request. * @param startTime - The time the execution attempt started. * @param outcome - The outcome code. * @param outcomeDesc - The outcome description text. */ export async function createBotAuditEvent( request: BotExecutionRequest, startTime: string, outcome: AuditEventOutcome, outcomeDesc: string ): Promise<void> { const { bot, runAs, requester, input, subscription, agent, device } = request; const trigger = bot.auditEventTrigger ?? 'always'; if ( trigger === 'never' || (trigger === 'on-error' && outcome === AuditEventOutcome.Success) || (trigger === 'on-output' && outcomeDesc.length === 0) ) { return; } const auditEvent: AuditEvent = { resourceType: 'AuditEvent', meta: { project: resolveId(runAs.project) as string, account: bot.meta?.account, accounts: bot.meta?.accounts, }, period: { start: startTime, end: new Date().toISOString(), }, recorded: new Date().toISOString(), type: { system: 'https://medplum.com/CodeSystem/audit-event', code: 'execute' }, agent: [ { who: applyOptionalRedaction(requester) as Reference<ProfileResource>, requestor: true, }, { who: applyOptionalRedaction(runAs.profile) as Reference<ProfileResource>, requestor: false, }, ], source: { observer: applyOptionalRedaction(createReference(bot)) as Reference<Bot> }, entity: createAuditEventEntities(bot, input, subscription, agent, device), outcome, outcomeDesc, extension: buildTracingExtension(), }; const config = getConfig(); for (const destination of bot.auditEventDestination ?? ['resource']) { switch (destination) { case 'resource': await getSystemRepo().createResource<AuditEvent>({ ...auditEvent, outcomeDesc: tail(outcomeDesc, config.maxBotLogLengthForResource ?? defaultBotOutputLength), }); break; case 'log': logAuditEvent({ ...auditEvent, outcomeDesc: tail(outcomeDesc, config.maxBotLogLengthForLogs ?? defaultBotOutputLength), }); break; } } } function tail(str: string, n: number): string { return str.substring(str.length - n); } /** * Creates an AuditEvent for a subscription attempt. * @param resource - The resource that triggered the subscription. * @param startTime - The time the subscription attempt started. * @param outcome - The outcome code. * @param outcomeDesc - The outcome description text. * @param subscription - Optional rest-hook subscription. * @param bot - Optional bot that was executed. */ export async function createSubscriptionAuditEvent( resource: Resource, startTime: string, outcome: AuditEventOutcome, outcomeDesc?: string, subscription?: Subscription, bot?: Bot ): Promise<void> { const systemRepo = getSystemRepo(); const auditedEvent = subscription ?? resource; await systemRepo.createResource<AuditEvent>({ resourceType: 'AuditEvent', meta: { project: auditedEvent.meta?.project, account: auditedEvent.meta?.account, accounts: auditedEvent.meta?.accounts, }, period: { start: startTime, end: new Date().toISOString(), }, recorded: new Date().toISOString(), type: { code: 'transmit', }, agent: [ { type: { text: auditedEvent.resourceType }, requestor: false, }, ], source: { observer: applyOptionalRedaction(createReference(auditedEvent)) as Reference as Reference<Practitioner>, }, entity: createAuditEventEntities(resource, subscription, bot), outcome, outcomeDesc, extension: buildTracingExtension(), }); } export function createAuditEventEntities(...resources: unknown[]): AuditEventEntity[] { return flatMapFilter(resources, (v) => (isResourceWithId(v) ? createAuditEventEntity(v) : undefined)); } export function createAuditEventEntity(resource: Resource): AuditEventEntity { return { what: applyOptionalRedaction(createReference(resource)), role: getAuditEventEntityRole(resource), }; } export function getAuditEventEntityRole(resource: Resource): Coding { switch (resource.resourceType) { case 'Patient': return { code: '1', display: 'Patient' }; case 'Subscription': return { code: '9', display: 'Subscriber' }; default: return { code: '4', display: 'Domain' }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server