001/*-
002 * #%L
003 * HAPI FHIR Storage api
004 * %%
005 * Copyright (C) 2014 - 2025 Smile CDR, Inc.
006 * %%
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 * You may obtain a copy of the License at
010 *
011 *      http://www.apache.org/licenses/LICENSE-2.0
012 *
013 * Unless required by applicable law or agreed to in writing, software
014 * distributed under the License is distributed on an "AS IS" BASIS,
015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
016 * See the License for the specific language governing permissions and
017 * limitations under the License.
018 * #L%
019 */
020package ca.uhn.fhir.storage.interceptor.balp;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.context.RuntimeResourceDefinition;
024import ca.uhn.fhir.context.RuntimeSearchParam;
025import ca.uhn.fhir.interceptor.api.Hook;
026import ca.uhn.fhir.interceptor.api.Pointcut;
027import ca.uhn.fhir.interceptor.auth.CompartmentSearchParameterModifications;
028import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
029import ca.uhn.fhir.rest.api.server.IPreResourceShowDetails;
030import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
031import ca.uhn.fhir.util.FhirTerser;
032import ca.uhn.fhir.util.UrlUtil;
033import jakarta.annotation.Nonnull;
034import org.apache.commons.lang3.Validate;
035import org.hl7.fhir.instance.model.api.IBaseResource;
036import org.hl7.fhir.r4.model.AuditEvent;
037import org.hl7.fhir.utilities.xhtml.XhtmlNode;
038
039import java.nio.charset.StandardCharsets;
040import java.util.Date;
041import java.util.HashSet;
042import java.util.List;
043import java.util.Map;
044import java.util.Set;
045import java.util.TreeSet;
046
047/**
048 * The IHE Basic Audit Logging Pattern (BALP) interceptor can be used to autopmatically generate
049 * AuditEvent resources that are conformant to the BALP profile in response to events in a
050 * FHIR server. See <a href="https://hapifhir.io/hapi-fhir/security/balp_interceptor.html">BALP Interceptor</a>
051 * in the HAPI FHIR documentation for more information.
052 *
053 * @since 6.6.0
054 */
055public class BalpAuditCaptureInterceptor {
056
057        private final IBalpAuditEventSink myAuditEventSink;
058        private final IBalpAuditContextServices myContextServices;
059        private Set<String> myAdditionalPatientCompartmentParamNames;
060
061        private Set<String> myOmittedSPNamesInPatientCompartment;
062
063        /**
064         * Constructor
065         *
066         * @param theAuditEventSink  This service is the target for generated AuditEvent resources. The {@link BalpAuditCaptureInterceptor}
067         *                           does not actually store AuditEvents, it simply generates them when appropriate and passes them to
068         *                           the sink service. The sink service might store them locally, transmit them to a remote
069         *                           repository, or even simply log them to a syslog.
070         * @param theContextServices This service supplies details to the BALP about the context of a given request. For example,
071         *                           in order to generate a conformant AuditEvent resource, this interceptor needs to determine the
072         *                           identity of the user and the client from the {@link ca.uhn.fhir.rest.api.server.RequestDetails}
073         *                           object.
074         */
075        public BalpAuditCaptureInterceptor(
076                        @Nonnull IBalpAuditEventSink theAuditEventSink, @Nonnull IBalpAuditContextServices theContextServices) {
077                Validate.notNull(theAuditEventSink);
078                Validate.notNull(theContextServices);
079                myAuditEventSink = theAuditEventSink;
080                myContextServices = theContextServices;
081        }
082
083        private static void addEntityPatient(AuditEvent theAuditEvent, String thePatientId) {
084                AuditEvent.AuditEventEntityComponent entityPatient = theAuditEvent.addEntity();
085                entityPatient
086                                .getType()
087                                .setSystem(BalpConstants.CS_AUDIT_ENTITY_TYPE)
088                                .setCode(BalpConstants.CS_AUDIT_ENTITY_TYPE_1_PERSON)
089                                .setDisplay(BalpConstants.CS_AUDIT_ENTITY_TYPE_1_PERSON_DISPLAY);
090                entityPatient
091                                .getRole()
092                                .setSystem(BalpConstants.CS_OBJECT_ROLE)
093                                .setCode(BalpConstants.CS_OBJECT_ROLE_1_PATIENT)
094                                .setDisplay(BalpConstants.CS_OBJECT_ROLE_1_PATIENT_DISPLAY);
095                entityPatient.getWhat().setReference(thePatientId);
096        }
097
098        private static void addEntityData(AuditEvent theAuditEvent, String theDataResourceId) {
099                AuditEvent.AuditEventEntityComponent entityData = theAuditEvent.addEntity();
100                entityData
101                                .getType()
102                                .setSystem(BalpConstants.CS_AUDIT_ENTITY_TYPE)
103                                .setCode(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT)
104                                .setDisplay(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT_DISPLAY);
105                entityData
106                                .getRole()
107                                .setSystem(BalpConstants.CS_OBJECT_ROLE)
108                                .setCode(BalpConstants.CS_OBJECT_ROLE_4_DOMAIN_RESOURCE)
109                                .setDisplay(BalpConstants.CS_OBJECT_ROLE_4_DOMAIN_RESOURCE_DISPLAY);
110                entityData.getWhat().setReference(theDataResourceId);
111        }
112
113        public void setAdditionalPatientCompartmentParamNames(Set<String> theAdditionalPatientCompartmentParamNames) {
114                myAdditionalPatientCompartmentParamNames = theAdditionalPatientCompartmentParamNames;
115        }
116
117        public void setOmittedSPNamesInPatientCompartment(Set<String> theOmittedSPNamesInPatientCompartment) {
118                myOmittedSPNamesInPatientCompartment = theOmittedSPNamesInPatientCompartment;
119        }
120
121        public Set<String> getAdditionalPatientCompartmentSPNames() {
122                if (myAdditionalPatientCompartmentParamNames == null) {
123                        myAdditionalPatientCompartmentParamNames = new HashSet<>();
124                }
125                return myAdditionalPatientCompartmentParamNames;
126        }
127
128        public Set<String> getOmittedPatientCompartmentSPNames() {
129                if (myOmittedSPNamesInPatientCompartment == null) {
130                        myOmittedSPNamesInPatientCompartment = new HashSet<>();
131                }
132                return myOmittedSPNamesInPatientCompartment;
133        }
134
135        /**
136         * Interceptor hook method. Do not call directly.
137         */
138        @SuppressWarnings("EnumSwitchStatementWhichMissesCases")
139        @Hook(Pointcut.STORAGE_PRESHOW_RESOURCES)
140        void hookStoragePreShowResources(IPreResourceShowDetails theDetails, ServletRequestDetails theRequestDetails) {
141                switch (theRequestDetails.getRestOperationType()) {
142                        case SEARCH_TYPE:
143                        case SEARCH_SYSTEM:
144                        case GET_PAGE:
145                                handleSearch(theDetails, theRequestDetails);
146                                break;
147                        case READ:
148                        case VREAD:
149                                handleReadOrVRead(theDetails, theRequestDetails);
150                                break;
151                        default:
152                                // No actions for other operations
153                }
154        }
155
156        @Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED)
157        public void hookStoragePrecommitResourceCreated(
158                        IBaseResource theResource, ServletRequestDetails theRequestDetails) {
159                handleCreateUpdateDelete(
160                                theResource, theRequestDetails, BalpProfileEnum.BASIC_CREATE, BalpProfileEnum.PATIENT_CREATE);
161        }
162
163        @Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_DELETED)
164        public void hookStoragePrecommitResourceDeleted(
165                        IBaseResource theResource, ServletRequestDetails theRequestDetails) {
166                handleCreateUpdateDelete(
167                                theResource, theRequestDetails, BalpProfileEnum.BASIC_DELETE, BalpProfileEnum.PATIENT_DELETE);
168        }
169
170        @Hook(Pointcut.STORAGE_PRECOMMIT_RESOURCE_UPDATED)
171        public void hookStoragePrecommitResourceUpdated(
172                        IBaseResource theOldResource, IBaseResource theResource, ServletRequestDetails theRequestDetails) {
173                handleCreateUpdateDelete(
174                                theResource, theRequestDetails, BalpProfileEnum.BASIC_UPDATE, BalpProfileEnum.PATIENT_UPDATE);
175        }
176
177        private void handleCreateUpdateDelete(
178                        IBaseResource theResource,
179                        ServletRequestDetails theRequestDetails,
180                        BalpProfileEnum theBasicProfile,
181                        BalpProfileEnum thePatientProfile) {
182                Set<String> patientCompartmentOwners =
183                                determinePatientCompartmentOwnersForResources(List.of(theResource), theRequestDetails);
184                if (patientCompartmentOwners.isEmpty()) {
185                        AuditEvent auditEvent =
186                                        createAuditEventBasicCreateUpdateDelete(theRequestDetails, theResource, theBasicProfile);
187                        myAuditEventSink.recordAuditEvent(auditEvent);
188                } else {
189                        AuditEvent auditEvent = createAuditEventPatientCreateUpdateDelete(
190                                        theRequestDetails, theResource, patientCompartmentOwners, thePatientProfile);
191                        myAuditEventSink.recordAuditEvent(auditEvent);
192                }
193        }
194
195        private void handleReadOrVRead(IPreResourceShowDetails theDetails, ServletRequestDetails theRequestDetails) {
196                Validate.isTrue(theDetails.size() == 1, "Unexpected number of results for read: %d", theDetails.size());
197                IBaseResource resource = theDetails.getResource(0);
198                if (resource != null) {
199                        String dataResourceId =
200                                        myContextServices.massageResourceIdForStorage(theRequestDetails, resource, resource.getIdElement());
201                        Set<String> patientIds =
202                                        determinePatientCompartmentOwnersForResources(List.of(resource), theRequestDetails);
203
204                        // If the resource is in the Patient compartment, create one audit
205                        // event for each compartment owner
206                        for (String patientId : patientIds) {
207                                AuditEvent auditEvent = createAuditEventPatientRead(theRequestDetails, dataResourceId, patientId);
208                                myAuditEventSink.recordAuditEvent(auditEvent);
209                        }
210
211                        // Otherwise, this is a basic read so create a basic read audit event
212                        if (patientIds.isEmpty()) {
213                                AuditEvent auditEvent = createAuditEventBasicRead(theRequestDetails, dataResourceId);
214                                myAuditEventSink.recordAuditEvent(auditEvent);
215                        }
216                }
217        }
218
219        private void handleSearch(IPreResourceShowDetails theDetails, ServletRequestDetails theRequestDetails) {
220
221                List<IBaseResource> resources = theDetails.getAllResources();
222                Set<String> compartmentOwners = determinePatientCompartmentOwnersForResources(resources, theRequestDetails);
223
224                if (!compartmentOwners.isEmpty()) {
225                        for (String owner : compartmentOwners) {
226                                AuditEvent auditEvent = createAuditEventPatientQuery(theRequestDetails, Set.of(owner));
227                                myAuditEventSink.recordAuditEvent(auditEvent);
228                        }
229
230                } else {
231                        AuditEvent auditEvent = createAuditEventBasicQuery(theRequestDetails);
232                        myAuditEventSink.recordAuditEvent(auditEvent);
233                }
234        }
235
236        @Nonnull
237        private Set<String> determinePatientCompartmentOwnersForResources(
238                        List<IBaseResource> theResources, ServletRequestDetails theRequestDetails) {
239                Set<String> patientIds = new TreeSet<>();
240                FhirContext fhirContext = theRequestDetails.getFhirContext();
241
242                for (IBaseResource resource : theResources) {
243                        RuntimeResourceDefinition resourceDef = fhirContext.getResourceDefinition(resource);
244                        if (resourceDef.getName().equals("Patient")) {
245                                patientIds.add(myContextServices.massageResourceIdForStorage(
246                                                theRequestDetails, resource, resource.getIdElement()));
247                        } else {
248                                List<RuntimeSearchParam> compartmentSearchParameters =
249                                                resourceDef.getSearchParamsForCompartmentName("Patient");
250                                if (!compartmentSearchParameters.isEmpty()) {
251                                        FhirTerser terser = fhirContext.newTerser();
252                                        terser
253                                                        .getCompartmentOwnersForResource(
254                                                                        "Patient",
255                                                                        resource,
256                                                                        CompartmentSearchParameterModifications.fromAdditionalAndOmittedSPNames(
257                                                                                        fhirContext.getResourceType(resource),
258                                                                                        getAdditionalPatientCompartmentSPNames(),
259                                                                                        getOmittedPatientCompartmentSPNames()))
260                                                        .stream()
261                                                        .map(t -> myContextServices.massageResourceIdForStorage(theRequestDetails, resource, t))
262                                                        .forEach(patientIds::add);
263                                }
264                        }
265                }
266                return patientIds;
267        }
268
269        @Nonnull
270        private AuditEvent createAuditEventCommonCreate(
271                        ServletRequestDetails theRequestDetails, IBaseResource theResource, BalpProfileEnum profile) {
272                AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, profile);
273
274                String resourceId = myContextServices.massageResourceIdForStorage(
275                                theRequestDetails, theResource, theResource.getIdElement());
276                addEntityData(auditEvent, resourceId);
277                return auditEvent;
278        }
279
280        @Nonnull
281        private AuditEvent createAuditEventBasicCreateUpdateDelete(
282                        ServletRequestDetails theRequestDetails, IBaseResource theResource, BalpProfileEnum theProfile) {
283                return createAuditEventCommonCreate(theRequestDetails, theResource, theProfile);
284        }
285
286        @Nonnull
287        private AuditEvent createAuditEventBasicQuery(ServletRequestDetails theRequestDetails) {
288                BalpProfileEnum profile = BalpProfileEnum.BASIC_QUERY;
289                AuditEvent auditEvent = createAuditEventCommonQuery(theRequestDetails, profile);
290                return auditEvent;
291        }
292
293        @Nonnull
294        private AuditEvent createAuditEventBasicRead(ServletRequestDetails theRequestDetails, String dataResourceId) {
295                return createAuditEventCommonRead(theRequestDetails, dataResourceId, BalpProfileEnum.BASIC_READ);
296        }
297
298        @Nonnull
299        private AuditEvent createAuditEventPatientCreateUpdateDelete(
300                        ServletRequestDetails theRequestDetails,
301                        IBaseResource theResource,
302                        Set<String> thePatientCompartmentOwners,
303                        BalpProfileEnum theProfile) {
304                AuditEvent retVal = createAuditEventCommonCreate(theRequestDetails, theResource, theProfile);
305                for (String next : thePatientCompartmentOwners) {
306                        addEntityPatient(retVal, next);
307                }
308                return retVal;
309        }
310
311        @Nonnull
312        private AuditEvent createAuditEventPatientQuery(
313                        ServletRequestDetails theRequestDetails, Set<String> compartmentOwners) {
314                BalpProfileEnum profile = BalpProfileEnum.PATIENT_QUERY;
315                AuditEvent auditEvent = createAuditEventCommonQuery(theRequestDetails, profile);
316                for (String next : compartmentOwners) {
317                        addEntityPatient(auditEvent, next);
318                }
319                return auditEvent;
320        }
321
322        @Nonnull
323        private AuditEvent createAuditEventPatientRead(
324                        ServletRequestDetails theRequestDetails, String dataResourceId, String patientId) {
325                BalpProfileEnum profile = BalpProfileEnum.PATIENT_READ;
326                AuditEvent auditEvent = createAuditEventCommonRead(theRequestDetails, dataResourceId, profile);
327                addEntityPatient(auditEvent, patientId);
328                return auditEvent;
329        }
330
331        @Nonnull
332        private AuditEvent createAuditEventCommon(ServletRequestDetails theRequestDetails, BalpProfileEnum theProfile) {
333                RestOperationTypeEnum restOperationType = theRequestDetails.getRestOperationType();
334                if (restOperationType == RestOperationTypeEnum.GET_PAGE) {
335                        restOperationType = RestOperationTypeEnum.SEARCH_TYPE;
336                }
337
338                AuditEvent auditEvent = new AuditEvent();
339                auditEvent.getMeta().addProfile(theProfile.getProfileUrl());
340                auditEvent
341                                .getText()
342                                .setDiv(new XhtmlNode().setValue("<div>Audit Event</div>"))
343                                .setStatus(org.hl7.fhir.r4.model.Narrative.NarrativeStatus.GENERATED);
344                auditEvent
345                                .getType()
346                                .setSystem(BalpConstants.CS_AUDIT_EVENT_TYPE)
347                                .setCode("rest")
348                                .setDisplay("Restful Operation");
349                auditEvent
350                                .addSubtype()
351                                .setSystem(BalpConstants.CS_RESTFUL_INTERACTION)
352                                .setCode(restOperationType.getCode())
353                                .setDisplay(restOperationType.getCode());
354                auditEvent.setAction(theProfile.getAction());
355                auditEvent.setOutcome(AuditEvent.AuditEventOutcome._0);
356                auditEvent.setRecorded(new Date());
357
358                auditEvent.getSource().getObserver().setDisplay(theRequestDetails.getFhirServerBase());
359
360                AuditEvent.AuditEventAgentComponent clientAgent = auditEvent.addAgent();
361                clientAgent.setWho(myContextServices.getAgentClientWho(theRequestDetails));
362                clientAgent.getType().addCoding(theProfile.getAgentClientTypeCoding());
363                clientAgent.getWho().setDisplay(myContextServices.getNetworkAddress(theRequestDetails));
364                clientAgent
365                                .getNetwork()
366                                .setAddress(myContextServices.getNetworkAddress(theRequestDetails))
367                                .setType(myContextServices.getNetworkAddressType(theRequestDetails));
368                clientAgent.setRequestor(false);
369
370                AuditEvent.AuditEventAgentComponent serverAgent = auditEvent.addAgent();
371                serverAgent.getType().addCoding(theProfile.getAgentServerTypeCoding());
372                serverAgent.getWho().setDisplay(theRequestDetails.getFhirServerBase());
373                serverAgent.getNetwork().setAddress(theRequestDetails.getFhirServerBase());
374                serverAgent.setRequestor(false);
375
376                AuditEvent.AuditEventAgentComponent userAgent = auditEvent.addAgent();
377                userAgent
378                                .getType()
379                                .addCoding()
380                                .setSystem("http://terminology.hl7.org/CodeSystem/v3-ParticipationType")
381                                .setCode("IRCP")
382                                .setDisplay("information recipient");
383                userAgent.setWho(myContextServices.getAgentUserWho(theRequestDetails));
384                userAgent.setRequestor(true);
385
386                AuditEvent.AuditEventEntityComponent entityTransaction = auditEvent.addEntity();
387                entityTransaction
388                                .getType()
389                                .setSystem("https://profiles.ihe.net/ITI/BALP/CodeSystem/BasicAuditEntityType")
390                                .setCode("XrequestId");
391                entityTransaction.getWhat().getIdentifier().setValue(theRequestDetails.getRequestId());
392                return auditEvent;
393        }
394
395        @Nonnull
396        private AuditEvent createAuditEventCommonQuery(ServletRequestDetails theRequestDetails, BalpProfileEnum profile) {
397                AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, profile);
398
399                AuditEvent.AuditEventEntityComponent queryEntity = auditEvent.addEntity();
400                queryEntity
401                                .getType()
402                                .setSystem(BalpConstants.CS_AUDIT_ENTITY_TYPE)
403                                .setCode(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT)
404                                .setDisplay(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT_DISPLAY);
405                queryEntity
406                                .getRole()
407                                .setSystem(BalpConstants.CS_OBJECT_ROLE)
408                                .setCode(BalpConstants.CS_OBJECT_ROLE_24_QUERY)
409                                .setDisplay(BalpConstants.CS_OBJECT_ROLE_24_QUERY_DISPLAY);
410
411                // Description
412                StringBuilder description = new StringBuilder();
413                description.append(theRequestDetails.getRequestType().name());
414                description.append(" ");
415                description.append(theRequestDetails.getCompleteUrl());
416                queryEntity.setDescription(description.toString());
417
418                // Query String
419                StringBuilder queryString = new StringBuilder();
420                queryString.append(theRequestDetails.getFhirServerBase());
421                queryString.append("/");
422                queryString.append(theRequestDetails.getRequestPath());
423                boolean first = true;
424                for (Map.Entry<String, String[]> nextEntrySet :
425                                theRequestDetails.getParameters().entrySet()) {
426                        for (String nextValue : nextEntrySet.getValue()) {
427                                if (first) {
428                                        queryString.append("?");
429                                        first = false;
430                                } else {
431                                        queryString.append("&");
432                                }
433                                queryString.append(UrlUtil.escapeUrlParam(nextEntrySet.getKey()));
434                                queryString.append("=");
435                                queryString.append(UrlUtil.escapeUrlParam(nextValue));
436                        }
437                }
438
439                queryEntity.getQueryElement().setValue(queryString.toString().getBytes(StandardCharsets.UTF_8));
440                return auditEvent;
441        }
442
443        @Nonnull
444        private AuditEvent createAuditEventCommonRead(
445                        ServletRequestDetails theRequestDetails, String theDataResourceId, BalpProfileEnum theProfile) {
446                AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, theProfile);
447                addEntityData(auditEvent, theDataResourceId);
448                return auditEvent;
449        }
450}