
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}