
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}