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;
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                        AuditEvent auditEvent = createAuditEventPatientQuery(theRequestDetails, compartmentOwners);
204                        myAuditEventSink.recordAuditEvent(auditEvent);
205                } else {
206                        AuditEvent auditEvent = createAuditEventBasicQuery(theRequestDetails);
207                        myAuditEventSink.recordAuditEvent(auditEvent);
208                }
209        }
210
211        @Nonnull
212        private Set<String> determinePatientCompartmentOwnersForResources(
213                        List<IBaseResource> theResources, ServletRequestDetails theRequestDetails) {
214                Set<String> patientIds = new TreeSet<>();
215                FhirContext fhirContext = theRequestDetails.getFhirContext();
216
217                for (IBaseResource resource : theResources) {
218                        RuntimeResourceDefinition resourceDef = fhirContext.getResourceDefinition(resource);
219                        if (resourceDef.getName().equals("Patient")) {
220                                patientIds.add(myContextServices.massageResourceIdForStorage(
221                                                theRequestDetails, resource, resource.getIdElement()));
222                        } else {
223                                List<RuntimeSearchParam> compartmentSearchParameters =
224                                                resourceDef.getSearchParamsForCompartmentName("Patient");
225                                if (!compartmentSearchParameters.isEmpty()) {
226                                        FhirTerser terser = fhirContext.newTerser();
227                                        terser
228                                                        .getCompartmentOwnersForResource(
229                                                                        "Patient", resource, myAdditionalPatientCompartmentParamNames)
230                                                        .stream()
231                                                        .map(t -> myContextServices.massageResourceIdForStorage(theRequestDetails, resource, t))
232                                                        .forEach(patientIds::add);
233                                }
234                        }
235                }
236                return patientIds;
237        }
238
239        @Nonnull
240        private AuditEvent createAuditEventCommonCreate(
241                        ServletRequestDetails theRequestDetails, IBaseResource theResource, BalpProfileEnum profile) {
242                AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, profile);
243
244                String resourceId = myContextServices.massageResourceIdForStorage(
245                                theRequestDetails, theResource, theResource.getIdElement());
246                addEntityData(auditEvent, resourceId);
247                return auditEvent;
248        }
249
250        @Nonnull
251        private AuditEvent createAuditEventBasicCreateUpdateDelete(
252                        ServletRequestDetails theRequestDetails, IBaseResource theResource, BalpProfileEnum theProfile) {
253                return createAuditEventCommonCreate(theRequestDetails, theResource, theProfile);
254        }
255
256        @Nonnull
257        private AuditEvent createAuditEventBasicQuery(ServletRequestDetails theRequestDetails) {
258                BalpProfileEnum profile = BalpProfileEnum.BASIC_QUERY;
259                AuditEvent auditEvent = createAuditEventCommonQuery(theRequestDetails, profile);
260                return auditEvent;
261        }
262
263        @Nonnull
264        private AuditEvent createAuditEventBasicRead(ServletRequestDetails theRequestDetails, String dataResourceId) {
265                return createAuditEventCommonRead(theRequestDetails, dataResourceId, BalpProfileEnum.BASIC_READ);
266        }
267
268        @Nonnull
269        private AuditEvent createAuditEventPatientCreateUpdateDelete(
270                        ServletRequestDetails theRequestDetails,
271                        IBaseResource theResource,
272                        Set<String> thePatientCompartmentOwners,
273                        BalpProfileEnum theProfile) {
274                AuditEvent retVal = createAuditEventCommonCreate(theRequestDetails, theResource, theProfile);
275                for (String next : thePatientCompartmentOwners) {
276                        addEntityPatient(retVal, next);
277                }
278                return retVal;
279        }
280
281        @Nonnull
282        private AuditEvent createAuditEventPatientQuery(
283                        ServletRequestDetails theRequestDetails, Set<String> compartmentOwners) {
284                BalpProfileEnum profile = BalpProfileEnum.PATIENT_QUERY;
285                AuditEvent auditEvent = createAuditEventCommonQuery(theRequestDetails, profile);
286                for (String next : compartmentOwners) {
287                        addEntityPatient(auditEvent, next);
288                }
289                return auditEvent;
290        }
291
292        @Nonnull
293        private AuditEvent createAuditEventPatientRead(
294                        ServletRequestDetails theRequestDetails, String dataResourceId, String patientId) {
295                BalpProfileEnum profile = BalpProfileEnum.PATIENT_READ;
296                AuditEvent auditEvent = createAuditEventCommonRead(theRequestDetails, dataResourceId, profile);
297                addEntityPatient(auditEvent, patientId);
298                return auditEvent;
299        }
300
301        @Nonnull
302        private AuditEvent createAuditEventCommon(ServletRequestDetails theRequestDetails, BalpProfileEnum theProfile) {
303                RestOperationTypeEnum restOperationType = theRequestDetails.getRestOperationType();
304                if (restOperationType == RestOperationTypeEnum.GET_PAGE) {
305                        restOperationType = RestOperationTypeEnum.SEARCH_TYPE;
306                }
307
308                AuditEvent auditEvent = new AuditEvent();
309                auditEvent.getMeta().addProfile(theProfile.getProfileUrl());
310                auditEvent
311                                .getText()
312                                .setDiv(new XhtmlNode().setValue("<div>Audit Event</div>"))
313                                .setStatus(org.hl7.fhir.r4.model.Narrative.NarrativeStatus.GENERATED);
314                auditEvent
315                                .getType()
316                                .setSystem(BalpConstants.CS_AUDIT_EVENT_TYPE)
317                                .setCode("rest")
318                                .setDisplay("Restful Operation");
319                auditEvent
320                                .addSubtype()
321                                .setSystem(BalpConstants.CS_RESTFUL_INTERACTION)
322                                .setCode(restOperationType.getCode())
323                                .setDisplay(restOperationType.getCode());
324                auditEvent.setAction(theProfile.getAction());
325                auditEvent.setOutcome(AuditEvent.AuditEventOutcome._0);
326                auditEvent.setRecorded(new Date());
327
328                auditEvent.getSource().getObserver().setDisplay(theRequestDetails.getFhirServerBase());
329
330                AuditEvent.AuditEventAgentComponent clientAgent = auditEvent.addAgent();
331                clientAgent.setWho(myContextServices.getAgentClientWho(theRequestDetails));
332                clientAgent.getType().addCoding(theProfile.getAgentClientTypeCoding());
333                clientAgent.getWho().setDisplay(myContextServices.getNetworkAddress(theRequestDetails));
334                clientAgent
335                                .getNetwork()
336                                .setAddress(myContextServices.getNetworkAddress(theRequestDetails))
337                                .setType(myContextServices.getNetworkAddressType(theRequestDetails));
338                clientAgent.setRequestor(false);
339
340                AuditEvent.AuditEventAgentComponent serverAgent = auditEvent.addAgent();
341                serverAgent.getType().addCoding(theProfile.getAgentServerTypeCoding());
342                serverAgent.getWho().setDisplay(theRequestDetails.getFhirServerBase());
343                serverAgent.getNetwork().setAddress(theRequestDetails.getFhirServerBase());
344                serverAgent.setRequestor(false);
345
346                AuditEvent.AuditEventAgentComponent userAgent = auditEvent.addAgent();
347                userAgent
348                                .getType()
349                                .addCoding()
350                                .setSystem("http://terminology.hl7.org/CodeSystem/v3-ParticipationType")
351                                .setCode("IRCP")
352                                .setDisplay("information recipient");
353                userAgent.setWho(myContextServices.getAgentUserWho(theRequestDetails));
354                userAgent.setRequestor(true);
355
356                AuditEvent.AuditEventEntityComponent entityTransaction = auditEvent.addEntity();
357                entityTransaction
358                                .getType()
359                                .setSystem("https://profiles.ihe.net/ITI/BALP/CodeSystem/BasicAuditEntityType")
360                                .setCode("XrequestId");
361                entityTransaction.getWhat().getIdentifier().setValue(theRequestDetails.getRequestId());
362                return auditEvent;
363        }
364
365        @Nonnull
366        private AuditEvent createAuditEventCommonQuery(ServletRequestDetails theRequestDetails, BalpProfileEnum profile) {
367                AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, profile);
368
369                AuditEvent.AuditEventEntityComponent queryEntity = auditEvent.addEntity();
370                queryEntity
371                                .getType()
372                                .setSystem(BalpConstants.CS_AUDIT_ENTITY_TYPE)
373                                .setCode(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT)
374                                .setDisplay(BalpConstants.CS_AUDIT_ENTITY_TYPE_2_SYSTEM_OBJECT_DISPLAY);
375                queryEntity
376                                .getRole()
377                                .setSystem(BalpConstants.CS_OBJECT_ROLE)
378                                .setCode(BalpConstants.CS_OBJECT_ROLE_24_QUERY)
379                                .setDisplay(BalpConstants.CS_OBJECT_ROLE_24_QUERY_DISPLAY);
380
381                // Description
382                StringBuilder description = new StringBuilder();
383                description.append(theRequestDetails.getRequestType().name());
384                description.append(" ");
385                description.append(theRequestDetails.getCompleteUrl());
386                queryEntity.setDescription(description.toString());
387
388                // Query String
389                StringBuilder queryString = new StringBuilder();
390                queryString.append(theRequestDetails.getFhirServerBase());
391                queryString.append("/");
392                queryString.append(theRequestDetails.getRequestPath());
393                boolean first = true;
394                for (Map.Entry<String, String[]> nextEntrySet :
395                                theRequestDetails.getParameters().entrySet()) {
396                        for (String nextValue : nextEntrySet.getValue()) {
397                                if (first) {
398                                        queryString.append("?");
399                                        first = false;
400                                } else {
401                                        queryString.append("&");
402                                }
403                                queryString.append(UrlUtil.escapeUrlParam(nextEntrySet.getKey()));
404                                queryString.append("=");
405                                queryString.append(UrlUtil.escapeUrlParam(nextValue));
406                        }
407                }
408
409                queryEntity.getQueryElement().setValue(queryString.toString().getBytes(StandardCharsets.UTF_8));
410                return auditEvent;
411        }
412
413        @Nonnull
414        private AuditEvent createAuditEventCommonRead(
415                        ServletRequestDetails theRequestDetails, String theDataResourceId, BalpProfileEnum theProfile) {
416                AuditEvent auditEvent = createAuditEventCommon(theRequestDetails, theProfile);
417                addEntityData(auditEvent, theDataResourceId);
418                return auditEvent;
419        }
420}