Skip to content

Commit bd47e2d

Browse files
committed
Redesign FP Date Shift Architecture
1 parent 2502019 commit bd47e2d

File tree

18 files changed

+1075
-751
lines changed

18 files changed

+1075
-751
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package care.smith.fts.cda.impl;
2+
3+
import care.smith.fts.api.DateShiftPreserve;
4+
import care.smith.fts.util.HttpClientConfig;
5+
import care.smith.fts.util.tca.TcaDomains;
6+
import java.io.File;
7+
import java.time.Duration;
8+
import java.util.Optional;
9+
10+
public record FhirPseudonymizerConfig(
11+
HttpClientConfig serviceUrl,
12+
File anonymizationConfig,
13+
TCAConfig trustCenterAgent,
14+
Duration maxDateShift,
15+
DateShiftPreserve dateShiftPreserve) {
16+
17+
public FhirPseudonymizerConfig(
18+
HttpClientConfig serviceUrl,
19+
File anonymizationConfig,
20+
TCAConfig trustCenterAgent,
21+
Duration maxDateShift,
22+
DateShiftPreserve dateShiftPreserve) {
23+
this.serviceUrl = serviceUrl;
24+
this.anonymizationConfig = anonymizationConfig;
25+
this.trustCenterAgent = trustCenterAgent;
26+
this.maxDateShift = maxDateShift;
27+
this.dateShiftPreserve = Optional.ofNullable(dateShiftPreserve).orElse(DateShiftPreserve.NONE);
28+
}
29+
30+
public record TCAConfig(HttpClientConfig server, TcaDomains domains) {}
31+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package care.smith.fts.cda.impl;
2+
3+
import static care.smith.fts.util.MediaTypes.APPLICATION_FHIR_JSON;
4+
import static care.smith.fts.util.RetryStrategies.defaultRetryStrategy;
5+
6+
import care.smith.fts.api.ConsentedPatientBundle;
7+
import care.smith.fts.api.DateShiftPreserve;
8+
import care.smith.fts.api.TransportBundle;
9+
import care.smith.fts.api.cda.Deidentificator;
10+
import care.smith.fts.util.fhir.DateShiftAnonymizer;
11+
import care.smith.fts.util.tca.TcaDomains;
12+
import care.smith.fts.util.tca.TransportMappingResponse;
13+
import io.micrometer.core.instrument.MeterRegistry;
14+
import java.time.Duration;
15+
import java.util.List;
16+
import java.util.Map;
17+
import java.util.Set;
18+
import java.util.regex.Pattern;
19+
import java.util.stream.Collectors;
20+
import lombok.extern.slf4j.Slf4j;
21+
import org.hl7.fhir.r4.model.Bundle;
22+
import org.springframework.http.MediaType;
23+
import org.springframework.web.reactive.function.client.WebClient;
24+
import reactor.core.publisher.Mono;
25+
26+
/**
27+
* Deidentification step using an external FHIR Pseudonymizer service. Handles date nullification
28+
* locally, delegates ID pseudonymization to FP (which calls TCA), then consolidates all mappings
29+
* via TCA.
30+
*/
31+
@Slf4j
32+
class FhirPseudonymizerStep implements Deidentificator {
33+
34+
private static final Pattern TID_PATTERN = Pattern.compile("[A-Za-z0-9_-]{32}");
35+
36+
private final WebClient fpClient;
37+
private final WebClient tcaClient;
38+
private final TcaDomains domains;
39+
private final Duration maxDateShift;
40+
private final DateShiftPreserve preserve;
41+
private final List<String> dateShiftPaths;
42+
private final MeterRegistry meterRegistry;
43+
44+
FhirPseudonymizerStep(
45+
WebClient fpClient,
46+
WebClient tcaClient,
47+
TcaDomains domains,
48+
Duration maxDateShift,
49+
DateShiftPreserve preserve,
50+
List<String> dateShiftPaths,
51+
MeterRegistry meterRegistry) {
52+
this.fpClient = fpClient;
53+
this.tcaClient = tcaClient;
54+
this.domains = domains;
55+
this.maxDateShift = maxDateShift;
56+
this.preserve = preserve;
57+
this.dateShiftPaths = dateShiftPaths;
58+
this.meterRegistry = meterRegistry;
59+
}
60+
61+
@Override
62+
public Mono<TransportBundle> deidentify(ConsentedPatientBundle bundle) {
63+
return Mono.defer(
64+
() -> {
65+
var patient = bundle.consentedPatient();
66+
var dateMappings = DateShiftAnonymizer.nullifyDates(bundle.bundle(), dateShiftPaths);
67+
68+
log.trace(
69+
"Nullified {} date elements, sending to FHIR Pseudonymizer", dateMappings.size());
70+
71+
return sendToFhirPseudonymizer(bundle.bundle())
72+
.flatMap(
73+
pseudonymizedBundle -> {
74+
var identityTIds = extractTransportIds(pseudonymizedBundle);
75+
if (identityTIds.isEmpty() && dateMappings.isEmpty()) {
76+
return Mono.empty();
77+
}
78+
return consolidateViaTca(
79+
patient.identifier(), identityTIds, dateMappings)
80+
.map(
81+
transferId ->
82+
new TransportBundle(pseudonymizedBundle, transferId));
83+
});
84+
});
85+
}
86+
87+
private Mono<Bundle> sendToFhirPseudonymizer(Bundle bundle) {
88+
return fpClient
89+
.post()
90+
.uri("/$de-identify")
91+
.headers(h -> h.setContentType(APPLICATION_FHIR_JSON))
92+
.headers(h -> h.setAccept(List.of(APPLICATION_FHIR_JSON)))
93+
.bodyValue(bundle)
94+
.retrieve()
95+
.bodyToMono(Bundle.class)
96+
.timeout(Duration.ofSeconds(60))
97+
.retryWhen(defaultRetryStrategy(meterRegistry, "sendToFhirPseudonymizer"))
98+
.doOnError(e -> log.error("FHIR Pseudonymizer call failed: {}", e.getMessage()));
99+
}
100+
101+
/**
102+
* Extracts transport IDs from the pseudonymized bundle. After FP processing, resource IDs that
103+
* were pseudonymized will be 32-char Base64URL tIDs from TCA.
104+
*/
105+
static Set<String> extractTransportIds(Bundle bundle) {
106+
return bundle.getEntry().stream()
107+
.map(Bundle.BundleEntryComponent::getResource)
108+
.filter(r -> r != null && r.hasId())
109+
.map(r -> r.getIdElement().getIdPart())
110+
.filter(id -> id != null && TID_PATTERN.matcher(id).matches())
111+
.collect(Collectors.toSet());
112+
}
113+
114+
private Mono<String> consolidateViaTca(
115+
String patientIdentifier, Set<String> identityTIds, Map<String, String> dateMappings) {
116+
117+
var request =
118+
new FpTransportMappingRequest(
119+
patientIdentifier, identityTIds, dateMappings, domains.dateShift(), maxDateShift, preserve);
120+
121+
log.trace(
122+
"Consolidating {} identity tIDs + {} date mappings via TCA",
123+
identityTIds.size(),
124+
dateMappings.size());
125+
126+
return tcaClient
127+
.post()
128+
.uri("/api/v2/cd/fhir-pseudonymizer/transport-mapping")
129+
.headers(h -> h.setContentType(MediaType.APPLICATION_JSON))
130+
.bodyValue(request)
131+
.retrieve()
132+
.bodyToMono(TransportMappingResponse.class)
133+
.timeout(Duration.ofSeconds(30))
134+
.retryWhen(defaultRetryStrategy(meterRegistry, "consolidateViaTca"))
135+
.doOnError(e -> log.error("TCA consolidation failed: {}", e.getMessage()))
136+
.map(TransportMappingResponse::transferId);
137+
}
138+
139+
/**
140+
* DTO matching TCA's FpTransportMappingRequest. Duplicated here to avoid cross-module dependency
141+
* on the TCA rest package.
142+
*/
143+
record FpTransportMappingRequest(
144+
String patientIdentifier,
145+
Set<String> transportIds,
146+
Map<String, String> dateMappings,
147+
String dateShiftDomain,
148+
Duration maxDateShift,
149+
DateShiftPreserve dateShiftPreserve) {}
150+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package care.smith.fts.cda.impl;
2+
3+
import static java.util.Objects.requireNonNull;
4+
5+
import care.smith.fts.api.cda.Deidentificator;
6+
import care.smith.fts.util.WebClientFactory;
7+
import care.smith.fts.util.fhir.DateShiftAnonymizer;
8+
import io.micrometer.core.instrument.MeterRegistry;
9+
import java.io.IOException;
10+
import java.util.List;
11+
import org.springframework.stereotype.Component;
12+
13+
@Component("fhir-pseudonymizerDeidentificator")
14+
public class FhirPseudonymizerStepFactory
15+
implements Deidentificator.Factory<FhirPseudonymizerConfig> {
16+
17+
private final WebClientFactory clientFactory;
18+
private final MeterRegistry meterRegistry;
19+
20+
public FhirPseudonymizerStepFactory(
21+
WebClientFactory clientFactory, MeterRegistry meterRegistry) {
22+
this.clientFactory = clientFactory;
23+
this.meterRegistry = meterRegistry;
24+
}
25+
26+
@Override
27+
public Class<FhirPseudonymizerConfig> getConfigType() {
28+
return FhirPseudonymizerConfig.class;
29+
}
30+
31+
@Override
32+
public Deidentificator create(
33+
Deidentificator.Config commonConfig, FhirPseudonymizerConfig implConfig) {
34+
var fpClient = clientFactory.create(implConfig.serviceUrl());
35+
var tcaClient = clientFactory.create(implConfig.trustCenterAgent().server());
36+
37+
List<String> dateShiftPaths;
38+
try {
39+
dateShiftPaths =
40+
DateShiftAnonymizer.parseDateShiftPaths(
41+
requireNonNull(implConfig.anonymizationConfig()));
42+
} catch (IOException e) {
43+
throw new IllegalStateException("Failed to parse anonymization config", e);
44+
}
45+
46+
return new FhirPseudonymizerStep(
47+
fpClient,
48+
tcaClient,
49+
implConfig.trustCenterAgent().domains(),
50+
implConfig.maxDateShift(),
51+
implConfig.dateShiftPreserve(),
52+
dateShiftPaths,
53+
meterRegistry);
54+
}
55+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package care.smith.fts.cda.impl;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.Mockito.lenient;
6+
import static org.mockito.Mockito.when;
7+
8+
import care.smith.fts.api.ConsentedPatient;
9+
import care.smith.fts.api.ConsentedPatientBundle;
10+
import care.smith.fts.api.DateShiftPreserve;
11+
import care.smith.fts.util.tca.TcaDomains;
12+
import care.smith.fts.util.tca.TransportMappingResponse;
13+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
14+
import java.time.Duration;
15+
import java.util.List;
16+
import java.util.function.Consumer;
17+
import org.hl7.fhir.r4.model.Bundle;
18+
import org.hl7.fhir.r4.model.Encounter;
19+
import org.hl7.fhir.r4.model.Patient;
20+
import org.junit.jupiter.api.BeforeEach;
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.extension.ExtendWith;
23+
import org.mockito.Mock;
24+
import org.mockito.junit.jupiter.MockitoExtension;
25+
import org.springframework.web.reactive.function.client.WebClient;
26+
import reactor.core.publisher.Mono;
27+
import reactor.test.StepVerifier;
28+
29+
@SuppressWarnings("unchecked")
30+
@ExtendWith(MockitoExtension.class)
31+
class FhirPseudonymizerStepTest {
32+
33+
@Mock private WebClient fpClient;
34+
@Mock private WebClient tcaClient;
35+
@Mock private WebClient.RequestBodyUriSpec fpPostSpec;
36+
@Mock private WebClient.RequestBodySpec fpBodySpec;
37+
@Mock private WebClient.RequestHeadersSpec fpHeadersSpec;
38+
@Mock private WebClient.ResponseSpec fpResponseSpec;
39+
@Mock private WebClient.RequestBodyUriSpec tcaPostSpec;
40+
@Mock private WebClient.RequestBodySpec tcaBodySpec;
41+
@Mock private WebClient.RequestHeadersSpec tcaHeadersSpec;
42+
@Mock private WebClient.ResponseSpec tcaResponseSpec;
43+
44+
private FhirPseudonymizerStep step;
45+
46+
@BeforeEach
47+
void setUp() {
48+
var domains = new TcaDomains("pseudo-domain", "salt-domain", "dateshift-domain");
49+
step =
50+
new FhirPseudonymizerStep(
51+
fpClient,
52+
tcaClient,
53+
domains,
54+
Duration.ofDays(14),
55+
DateShiftPreserve.NONE,
56+
List.of(),
57+
new SimpleMeterRegistry());
58+
59+
// FP client mock chain (lenient: not all tests exercise FP)
60+
lenient().when(fpClient.post()).thenReturn(fpPostSpec);
61+
lenient().when(fpPostSpec.uri(any(String.class))).thenReturn(fpBodySpec);
62+
lenient().when(fpBodySpec.headers(any(Consumer.class))).thenReturn(fpBodySpec);
63+
lenient().when(fpBodySpec.bodyValue(any())).thenReturn(fpHeadersSpec);
64+
lenient().when(fpHeadersSpec.retrieve()).thenReturn(fpResponseSpec);
65+
66+
// TCA client mock chain (lenient: not all tests exercise TCA)
67+
lenient().when(tcaClient.post()).thenReturn(tcaPostSpec);
68+
lenient().when(tcaPostSpec.uri(any(String.class))).thenReturn(tcaBodySpec);
69+
lenient().when(tcaBodySpec.headers(any(Consumer.class))).thenReturn(tcaBodySpec);
70+
lenient().when(tcaBodySpec.bodyValue(any())).thenReturn(tcaHeadersSpec);
71+
lenient().when(tcaHeadersSpec.retrieve()).thenReturn(tcaResponseSpec);
72+
}
73+
74+
@Test
75+
void extractTransportIdsFinds32CharBase64UrlIds() {
76+
var bundle = new Bundle();
77+
78+
var patient = new Patient();
79+
patient.setId("AbCdEfGhIjKlMnOpQrStUvWxYz012345"); // 32 chars Base64URL
80+
bundle.addEntry().setResource(patient);
81+
82+
var encounter = new Encounter();
83+
encounter.setId("short-id"); // Not a tID
84+
bundle.addEntry().setResource(encounter);
85+
86+
var encounter2 = new Encounter();
87+
encounter2.setId("a-uuid-that-is-longer-than-32-chars-total"); // Not a 32-char tID
88+
bundle.addEntry().setResource(encounter2);
89+
90+
var tIds = FhirPseudonymizerStep.extractTransportIds(bundle);
91+
92+
assertThat(tIds).containsExactly("AbCdEfGhIjKlMnOpQrStUvWxYz012345");
93+
}
94+
95+
@Test
96+
void extractTransportIdsReturnsEmptyForNoMatches() {
97+
var bundle = new Bundle();
98+
var patient = new Patient();
99+
patient.setId("regular-uuid-id");
100+
bundle.addEntry().setResource(patient);
101+
102+
var tIds = FhirPseudonymizerStep.extractTransportIds(bundle);
103+
104+
assertThat(tIds).isEmpty();
105+
}
106+
107+
@Test
108+
void deidentifyReturnsBundleWithTransferId() {
109+
var pseudonymizedBundle = new Bundle();
110+
var pseudoPatient = new Patient();
111+
pseudoPatient.setId("AbCdEfGhIjKlMnOpQrStUvWxYz012345");
112+
pseudonymizedBundle.addEntry().setResource(pseudoPatient);
113+
114+
when(fpResponseSpec.bodyToMono(Bundle.class)).thenReturn(Mono.just(pseudonymizedBundle));
115+
when(tcaResponseSpec.bodyToMono(TransportMappingResponse.class))
116+
.thenReturn(Mono.just(new TransportMappingResponse("transfer-id-abc")));
117+
118+
var patient = new ConsentedPatient("patient-1", "http://system");
119+
var inputBundle = new Bundle();
120+
inputBundle.addEntry().setResource(new Patient());
121+
122+
var result = step.deidentify(new ConsentedPatientBundle(inputBundle, patient));
123+
124+
StepVerifier.create(result)
125+
.assertNext(
126+
transport -> {
127+
assertThat(transport.transferId()).isEqualTo("transfer-id-abc");
128+
assertThat(transport.bundle()).isEqualTo(pseudonymizedBundle);
129+
})
130+
.verifyComplete();
131+
}
132+
133+
@Test
134+
void deidentifyReturnsEmptyWhenNoMappings() {
135+
var emptyBundle = new Bundle();
136+
137+
when(fpResponseSpec.bodyToMono(Bundle.class)).thenReturn(Mono.just(emptyBundle));
138+
139+
var patient = new ConsentedPatient("patient-1", "http://system");
140+
var inputBundle = new Bundle();
141+
142+
var result = step.deidentify(new ConsentedPatientBundle(inputBundle, patient));
143+
144+
StepVerifier.create(result).verifyComplete();
145+
}
146+
}

0 commit comments

Comments
 (0)