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