Skip to content

Commit 967c8b6

Browse files
LEGLINK-961: Measure eval handle fhir resourceswith identicalds (#1839)
* LEGLINK-961: Handle contained FHIR resources with identical IDs across types BlobStorageService.normalize collected the MeasureReport's contained resources into a map keyed by ID part alone via Collectors.toMap, which throws IllegalStateException when two resources of different types share an ID part (e.g. Condition/A and Observation/A) - legal in FHIR since IDs are only unique within a type. This dead-lettered the patient's report. Replace the single map with a type-qualified index (ResourceType/idPart) used for typed reference resolution, plus a secondary id-part-only index that's consulted only for untyped "#id" contained references, and only resolved when unambiguous. Ambiguous untyped references are left unresolved and logged with a warning rather than guessing. Claude-Session: https://claude.ai/code/session_014XemaT8rQoX1w55yZavZbV * LEGLINK-961: Sanitize the ambiguous-reference warning arguments idPart comes from a Reference in externally-sourced FHIR data, so it is attacker-influenced: a control character in it - a newline in particular - could break out of the log line and forge subsequent entries. Pass it through LogUtils.sanitize, which maps printable 32-255 to themselves and replaces everything else with a space. candidateTypes is built from ResourceType enum names and cannot carry control characters, so sanitizing it is not load-bearing; done anyway so both string arguments are treated uniformly and the next person editing this line does not have to work out which one is safe. candidates.size() is left numeric - it is an int, not attacker input, and sanitizing would only stringify it. No behavioural change: the printable characters in these values pass through unchanged, so BlobStorageServiceTest's assertion on the logged message still matches. Tests: measureeval 88, shared 7, 0 failures. Claude-Session: https://claude.ai/code/session_01AZubeEYCKfAmDCcgVTr18G
1 parent 0aa808c commit 967c8b6

2 files changed

Lines changed: 137 additions & 6 deletions

File tree

Java/measureeval/src/main/java/com/lantanagroup/link/measureeval/services/BlobStorageService.java

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import com.lantanagroup.link.shared.entities.ReportScheduleModel;
1111
import com.lantanagroup.link.shared.exceptions.ValidationException;
1212
import com.lantanagroup.link.shared.services.ReportClient;
13+
import com.lantanagroup.link.shared.utils.LogUtils;
14+
import org.hl7.fhir.instance.model.api.IIdType;
1315
import org.hl7.fhir.r4.model.IdType;
1416
import org.hl7.fhir.r4.model.MeasureReport;
1517
import org.hl7.fhir.r4.model.Reference;
@@ -19,10 +21,10 @@
1921

2022
import java.net.URI;
2123
import java.util.ArrayList;
24+
import java.util.HashMap;
2225
import java.util.List;
2326
import java.util.Map;
2427
import java.util.UUID;
25-
import java.util.function.Function;
2628
import java.util.stream.Collectors;
2729

2830
public class BlobStorageService {
@@ -114,9 +116,18 @@ private List<Resource> normalize(MeasureReport measureReport) {
114116
}
115117

116118
List<Resource> contained = measureReport.getContained();
117-
Map<String, Resource> containedByIdPart = contained.stream().collect(Collectors.toMap(
118-
resource -> stripHash(resource.getIdPart()),
119-
Function.identity()));
119+
120+
// Contained resource IDs are only guaranteed unique within a resource type (e.g. Condition/A and
121+
// Observation/A can legally coexist), so the primary lookup is type-qualified. A secondary,
122+
// id-part-only index is kept to resolve untyped "#id" references, but only when unambiguous.
123+
Map<String, Resource> containedByTypedId = new HashMap<>();
124+
Map<String, List<Resource>> containedByIdPart = new HashMap<>();
125+
for (Resource resource : contained) {
126+
String idPart = stripHash(resource.getIdPart());
127+
containedByTypedId.put(resource.getResourceType().name() + "/" + idPart, resource);
128+
containedByIdPart.computeIfAbsent(idPart, key -> new ArrayList<>()).add(resource);
129+
}
130+
120131
measureReport.setContained(null);
121132
measureReport.setEvaluatedResource(null);
122133
for (Resource resource : contained) {
@@ -127,8 +138,26 @@ private List<Resource> normalize(MeasureReport measureReport) {
127138
}
128139
for (Reference reference :
129140
fhirContext.newTerser().getAllPopulatedChildElementsOfType(measureReport, Reference.class)) {
130-
String idPart = stripHash(reference.getReferenceElement().getIdPart());
131-
Resource resource = containedByIdPart.get(idPart);
141+
IIdType referenceElement = reference.getReferenceElement();
142+
String idPart = stripHash(referenceElement.getIdPart());
143+
String resourceType = referenceElement.getResourceType();
144+
145+
Resource resource;
146+
if (resourceType != null) {
147+
resource = containedByTypedId.get(resourceType + "/" + idPart);
148+
} else {
149+
List<Resource> candidates = containedByIdPart.getOrDefault(idPart, List.of());
150+
if (candidates.size() > 1) {
151+
String candidateTypes = candidates.stream()
152+
.map(candidate -> candidate.getResourceType().name())
153+
.collect(Collectors.joining(", "));
154+
logger.warn("Contained reference '#{}' is ambiguous between {} resources ({}); leaving reference unresolved",
155+
LogUtils.sanitize(idPart), candidates.size(), LogUtils.sanitize(candidateTypes));
156+
continue;
157+
}
158+
resource = candidates.isEmpty() ? null : candidates.get(0);
159+
}
160+
132161
if (resource == null) {
133162
continue;
134163
}

Java/measureeval/src/test/java/com/lantanagroup/link/measureeval/services/BlobStorageServiceTest.java

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
package com.lantanagroup.link.measureeval.services;
22

33
import ca.uhn.fhir.context.FhirContext;
4+
import ch.qos.logback.classic.Level;
5+
import ch.qos.logback.classic.Logger;
6+
import ch.qos.logback.classic.LoggerContext;
7+
import ch.qos.logback.classic.spi.ILoggingEvent;
8+
import ch.qos.logback.core.read.ListAppender;
49
import com.azure.core.util.BinaryData;
510
import com.azure.storage.blob.BlobClient;
611
import com.azure.storage.blob.BlobContainerClient;
712
import com.lantanagroup.link.measureeval.entities.PatientReportingEvaluationStatus;
813
import com.lantanagroup.link.shared.entities.ReportScheduleModel;
914
import com.lantanagroup.link.shared.exceptions.ValidationException;
1015
import com.lantanagroup.link.shared.services.ReportClient;
16+
import org.hl7.fhir.r4.model.Condition;
1117
import org.hl7.fhir.r4.model.MeasureReport;
18+
import org.hl7.fhir.r4.model.Observation;
1219
import org.hl7.fhir.r4.model.Patient;
1320
import org.hl7.fhir.r4.model.Reference;
21+
import org.junit.jupiter.api.AfterEach;
1422
import org.junit.jupiter.api.BeforeEach;
1523
import org.junit.jupiter.api.Test;
1624
import org.mockito.ArgumentCaptor;
25+
import org.slf4j.LoggerFactory;
1726

1827
import java.util.List;
1928

@@ -142,6 +151,99 @@ void testStorePatientInBlobStorageWithContainedResources() {
142151
assertTrue(content.contains("MeasureReport/mr1"));
143152
}
144153

154+
@Test
155+
void testStorePatientInBlobStorageWithDuplicateIdPartAcrossTypes() {
156+
PatientReportingEvaluationStatus status = new PatientReportingEvaluationStatus();
157+
status.setPatientId("patient1");
158+
PatientReportingEvaluationStatus.Report report = new PatientReportingEvaluationStatus.Report();
159+
report.setReportTrackingId("track1");
160+
report.setReportType("type1");
161+
status.setReports(List.of(report));
162+
163+
ReportScheduleModel schedule = new ReportScheduleModel();
164+
schedule.setPayloadRootUri("https://storage.com/test-container/root/");
165+
when(reportClient.getReportSchedule("track1")).thenReturn(schedule);
166+
167+
MeasureReport measureReport = new MeasureReport();
168+
measureReport.setId("mr1");
169+
170+
// Two contained resources of different types sharing the same ID part.
171+
// Previously this threw IllegalStateException from Collectors.toMap.
172+
Condition condition = new Condition();
173+
condition.setId("#LCR-A");
174+
measureReport.addContained(condition);
175+
176+
Observation observation = new Observation();
177+
observation.setId("#LCR-A");
178+
measureReport.addContained(observation);
179+
180+
blobStorageService.storePatientInBlobStorage(status, report, measureReport);
181+
182+
ArgumentCaptor<BinaryData> contentCaptor = ArgumentCaptor.forClass(BinaryData.class);
183+
verify(blobClient).upload(contentCaptor.capture(), eq(true));
184+
String content = contentCaptor.getValue().toString();
185+
186+
assertTrue(content.contains("Condition/A"));
187+
assertTrue(content.contains("Observation/A"));
188+
}
189+
190+
@Test
191+
void testStorePatientInBlobStorageWithAmbiguousUntypedReferenceIsSkippedAndLogged() {
192+
Logger blobStorageServiceLogger =
193+
(Logger) LoggerFactory.getLogger(BlobStorageService.class);
194+
ListAppender<ILoggingEvent> logAppender = new ListAppender<>();
195+
logAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory());
196+
logAppender.start();
197+
blobStorageServiceLogger.addAppender(logAppender);
198+
199+
try {
200+
PatientReportingEvaluationStatus status = new PatientReportingEvaluationStatus();
201+
status.setPatientId("patient1");
202+
PatientReportingEvaluationStatus.Report report = new PatientReportingEvaluationStatus.Report();
203+
report.setReportTrackingId("track1");
204+
report.setReportType("type1");
205+
status.setReports(List.of(report));
206+
207+
ReportScheduleModel schedule = new ReportScheduleModel();
208+
schedule.setPayloadRootUri("https://storage.com/test-container/root/");
209+
when(reportClient.getReportSchedule("track1")).thenReturn(schedule);
210+
211+
MeasureReport measureReport = new MeasureReport();
212+
measureReport.setId("mr1");
213+
214+
Condition condition = new Condition();
215+
condition.setId("#LCR-A");
216+
measureReport.addContained(condition);
217+
218+
Observation observation = new Observation();
219+
observation.setId("#LCR-A");
220+
measureReport.addContained(observation);
221+
222+
// Untyped contained reference to the ambiguous ID part - cannot be resolved deterministically.
223+
measureReport.setSubject(new Reference("#LCR-A"));
224+
225+
blobStorageService.storePatientInBlobStorage(status, report, measureReport);
226+
227+
ArgumentCaptor<BinaryData> contentCaptor = ArgumentCaptor.forClass(BinaryData.class);
228+
verify(blobClient).upload(contentCaptor.capture(), eq(true));
229+
String content = contentCaptor.getValue().toString();
230+
231+
// First line is the "MeasureReport/<id>" marker, second is the MeasureReport JSON itself.
232+
String measureReportJson = content.split("\n")[1];
233+
MeasureReport parsed = fhirContext.newJsonParser().parseResource(MeasureReport.class, measureReportJson);
234+
235+
// The ambiguous reference is left unresolved rather than guessing which resource it means.
236+
assertEquals("#LCR-A", parsed.getSubject().getReference());
237+
238+
boolean warningLogged = logAppender.list.stream()
239+
.anyMatch(event -> event.getLevel() == Level.WARN
240+
&& event.getFormattedMessage().contains("LCR-A"));
241+
assertTrue(warningLogged, "Expected a warning to be logged for the ambiguous contained reference");
242+
} finally {
243+
blobStorageServiceLogger.detachAppender(logAppender);
244+
}
245+
}
246+
145247
@Test
146248
void testStorePatientInBlobStoragePayloadUriNull() {
147249
PatientReportingEvaluationStatus status = new PatientReportingEvaluationStatus();

0 commit comments

Comments
 (0)