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