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}