From dfc9de78ceb8753392aac16e99dfc25346634ac9 Mon Sep 17 00:00:00 2001 From: jlidke <67630067+jlidke@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:11:47 +0200 Subject: [PATCH] 119 add transaction (#124) --- README.md | 35 ++++--- .../pseudonym/GpasPseudonymGeneratorTest.kt | 6 +- .../etl/processor/pseudonym/Generator.java | 2 + .../pseudonym/GpasPseudonymGenerator.java | 93 +++++++++++++++---- .../processor/pseudonym/PsnDomainType.java | 12 +++ .../processor/config/AppConfigProperties.kt | 3 +- .../pseudonym/AnonymizingGenerator.kt | 15 ++- .../pseudonym/PseudonymizeService.kt | 4 + .../etl/processor/pseudonym/extensions.kt | 5 + .../processor/services/ConsentProcessor.kt | 2 +- .../processor/services/RequestProcessor.kt | 17 +++- .../pseudonym/PseudonymizeServiceTest.kt | 16 +++- 12 files changed, 172 insertions(+), 38 deletions(-) create mode 100644 src/main/java/dev/dnpm/etl/processor/pseudonym/PsnDomainType.java diff --git a/README.md b/README.md index 1307af1..23740ca 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,7 @@ Duplikate werden verworfen, Änderungen werden weitergeleitet. Löschanfragen werden immer als Löschanfrage an DNPM:DIP weitergeleitet. -Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand -der Anwendung gewährt. +Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt. ![Modell DNPM-ETL-Strecke](docs/etl.png) @@ -26,6 +25,18 @@ Konfigurationsparameter ### Modelvorhaben genomDE §64e +#### Vorgangsummern +Zusätzlich zur Patienten Identifier Pseudonymisierung müssen Vorgangsummern generiert werden, die +jede Übertragung eindeutig identifizieren aber gleichzeitig dem Patienten zugeordnet werden können. +Dies lässt sich durch weitere Pseudonyme abbilden, allerdings werden pro Originalwert mehrere +Pseudonyme benötigt. +Zu diesem Zweck muss in gPas eine **Multi-Pseudonym-Domäne** konfiguriert werden (siehe auch +*APP_PSEUDONYMIZE_GPAS_CCDN*). + +**WICHTIG:** Deaktivierte Pseudonymisierung ist nur für Tests nutzbar. Vorgangsummern sind zufällig +und werden anschließend verworfen. + +#### Test Betriebsbereitschaft Um die voll Betriebsbereitschaft herzustellen, muss eine erfolgreiche Übertragung mit dem Submission-Typ *Test* erfolgt sein. Über die Umgebungsvariable wird dieser Übertragungsmodus aktiviert. Alle Datensätze mit erteilter Teilnahme am Modelvorhaben werden mit der Test-Kennung @@ -98,20 +109,21 @@ vergleichbare IDs bereitzustellen. #### Eingebaute Anonymisierung Wurde keine oder die Verwendung der eingebauten Anonymisierung konfiguriert, so wird für die -Patienten-ID der -entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des -konfigurierten Präfixes -als Patienten-Pseudonym verwendet. +Patienten-ID der entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende +"=" - zuzüglich des konfigurierten Präfixes als Patienten-Pseudonym verwendet. #### Pseudonymisierung mit gPAS -Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfigurieren. +Wurde die Verwendung von gPAS konfiguriert, so sind weitere Angaben zu konfigurieren. -* `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz inklusive Endpoint (z.B. - `http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`) -* `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname +Ab Version 2025.1 (Multi-Pseudonym Support) + +* `APP_PSEUDONYMIZE_GPAS_URI`: URI der gPAS-Instanz REST API (e.g. http://127.0.0.1:9990/ttp-fhir/fhir/gpas) * `APP_PSEUDONYMIZE_GPAS_USERNAME`: gPas Basic-Auth Benutzername * `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort +* `APP_PSEUDONYMIZE_GPAS_PID_DOMAIN`: gPas Domänenname für Patienten ID +* `APP_PSEUDONYMIZE_GPAS_GENOM_DE_TAN_DOMAIN`: gPas Multi-Pseudonym-Domäne für genomDE Vorgangsnummern ( + Clinical data node) ### (Externe) Consent-Services @@ -173,8 +185,7 @@ Modelvorhaben §64e. ### Anmeldung mit einem Passwort Ein initialer Administrator-Account kann optional konfiguriert werden und sorgt dafür, dass -bestimmte Bereiche nur nach -einem erfolgreichen Login erreichbar sind. +bestimmte Bereiche nur nach einem erfolgreichen Login erreichbar sind. * `APP_SECURITY_ADMIN_USER`: Muss angegeben werden zur Aktivierung der Zugriffsbeschränkung. * `APP_SECURITY_ADMIN_PASSWORD`: Das Passwort für den Administrator (Empfohlen). diff --git a/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt index 1275239..c2a8ba6 100644 --- a/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt +++ b/src/integrationTest/kotlin/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGeneratorTest.kt @@ -43,14 +43,14 @@ class GpasPseudonymGeneratorTest { private lateinit var mockRestServiceServer: MockRestServiceServer private lateinit var generator: GpasPseudonymGenerator private lateinit var restTemplate: RestTemplate - private var appFhirConfig: AppFhirConfig = AppFhirConfig() + private var appFhirConfig: AppFhirConfig = AppFhirConfig() @BeforeEach fun setup() { val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() val gPasConfigProperties = GPasConfigProperties( - "https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate", - "test", + "https://localhost:9990/ttp-fhir/fhir/gpas", + "test", "test2", null, null ) diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/Generator.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/Generator.java index e1938ba..8d0d0c1 100644 --- a/src/main/java/dev/dnpm/etl/processor/pseudonym/Generator.java +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/Generator.java @@ -23,4 +23,6 @@ public interface Generator { String generate(String id); + String generateGenomDeTan(String id); + } diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java index a22100b..6a2b947 100644 --- a/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/GpasPseudonymGenerator.java @@ -23,7 +23,11 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.parser.IParser; import dev.dnpm.etl.processor.config.AppFhirConfig; import dev.dnpm.etl.processor.config.GPasConfigProperties; +import java.net.URI; +import java.net.URISyntaxException; +import org.apache.commons.lang3.NotImplementedException; import org.apache.commons.lang3.StringUtils; +import org.apache.hc.core5.net.URIBuilder; import org.hl7.fhir.r4.model.Identifier; import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; @@ -42,38 +46,67 @@ public class GpasPseudonymGenerator implements Generator { private final FhirContext r4Context; private final String gPasUrl; - private final String psnTargetDomain; private final HttpHeaders httpHeader; private final RetryTemplate retryTemplate; private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class); - private final RestTemplate restTemplate; + private final @NotNull String genomDeTanDomain; + private final @NotNull String pidPsnDomain; + protected final static String createOrGetPsn = "$pseudonymizeAllowCreate"; + protected final static String createMultiDomainPsn = "$pseudonymize-secondary"; + private final static String SINGLE_PSN_PART_NAME = "pseudonym"; + private final static String MULTI_PSN_PART_NAME = "value"; public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate, RestTemplate restTemplate, AppFhirConfig appFhirConfig) { this.retryTemplate = retryTemplate; this.restTemplate = restTemplate; this.gPasUrl = gpasCfg.getUri(); - this.psnTargetDomain = gpasCfg.getTarget(); + this.pidPsnDomain = gpasCfg.getPatientDomain(); + this.genomDeTanDomain = gpasCfg.getGenomDeTanDomain(); this.r4Context = appFhirConfig.fhirContext(); httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); - log.debug(String.format("%s has been initialized", this.getClass().getName())); + log.debug("{} has been initialized", this.getClass().getName()); } @Override public String generate(String id) { - var gPasRequestBody = getGpasRequestBody(id); - var responseEntity = getGpasPseudonym(gPasRequestBody); - var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() - .parseResource(responseEntity.getBody()); + return generate(id, PsnDomainType.SINGLE_PSN_DOMAIN); + } - return unwrapPseudonym(gPasPseudonymResult); + @Override + public String generateGenomDeTan(String id) { + return generate(id, PsnDomainType.MULTI_PSN_DOMAIN); + } + + protected String generate(String id, PsnDomainType domainType) { + switch (domainType) { + case SINGLE_PSN_DOMAIN -> { + final var requestBody = createSinglePsnRequestBody(id, pidPsnDomain); + final var responseEntity = getGpasPseudonym(requestBody, createOrGetPsn); + final var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() + .parseResource(responseEntity.getBody()); + + return unwrapPseudonym(gPasPseudonymResult, SINGLE_PSN_PART_NAME); + } + case MULTI_PSN_DOMAIN -> { + final var requestBody = createMultiPsnRequestBody(id, genomDeTanDomain); + final var responseEntity = getGpasPseudonym(requestBody, createMultiDomainPsn); + final var gPasPseudonymResult = (Parameters) r4Context.newJsonParser() + .parseResource(responseEntity.getBody()); + + return unwrapPseudonym(gPasPseudonymResult, MULTI_PSN_PART_NAME); + } + } + throw new NotImplementedException( + "give domain type '%s' is unexpected and is currently not supported!".formatted( + domainType)); } @NotNull - public static String unwrapPseudonym(Parameters gPasPseudonymResult) { + public static String unwrapPseudonym(Parameters gPasPseudonymResult, String targetPartName) { final var parameters = gPasPseudonymResult.getParameter().stream().findFirst(); if (parameters.isEmpty()) { @@ -81,7 +114,7 @@ public class GpasPseudonymGenerator implements Generator { } final var identifier = (Identifier) parameters.get().getPart().stream() - .filter(a -> a.getName().equals("pseudonym")) + .filter(a -> a.getName().equals(targetPartName)) .findFirst() .orElseGet(ParametersParameterComponent::new).getValue(); @@ -104,13 +137,14 @@ public class GpasPseudonymGenerator implements Generator { } @NotNull - protected ResponseEntity getGpasPseudonym(String gPasRequestBody) { + protected ResponseEntity getGpasPseudonym(String gPasRequestBody, String apiEndpoint) { HttpEntity requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader); try { + var targetUrl = buildRequestUrl(apiEndpoint); ResponseEntity responseEntity = retryTemplate.execute( - ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, + ctx -> restTemplate.exchange(targetUrl, HttpMethod.POST, requestEntity, String.class)); if (responseEntity.getStatusCode().is2xxSuccessful()) { log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); @@ -139,16 +173,43 @@ public class GpasPseudonymGenerator implements Generator { } - protected String getGpasRequestBody(String id) { - var requestParameters = new Parameters(); + protected URI buildRequestUrl(String apiEndpoint) throws URISyntaxException { + var gPasUrl1 = gPasUrl; + if (gPasUrl.lastIndexOf("/") == gPasUrl.length() - 1) { + gPasUrl1 = gPasUrl.substring(0, gPasUrl.length() - 1); + } + var urlBuilder = new URIBuilder(new URI(gPasUrl1)).appendPath(apiEndpoint); + + return urlBuilder.build(); + } + + protected String createSinglePsnRequestBody(String id, String targetDomain) { + final var requestParameters = new Parameters(); requestParameters.addParameter().setName("target") - .setValue(new StringType().setValue(psnTargetDomain)); + .setValue(new StringType().setValue(targetDomain)); requestParameters.addParameter().setName("original") .setValue(new StringType().setValue(id)); final IParser iParser = r4Context.newJsonParser(); return iParser.encodeResourceToString(requestParameters); } + protected String createMultiPsnRequestBody(String id, String targetDomain) { + final var param = new Parameters(); + ParametersParameterComponent targetParam = param.addParameter().setName("original"); + targetParam.addPart( + new ParametersParameterComponent().setName("target") + .setValue(new StringType(targetDomain))); + targetParam.addPart( + new ParametersParameterComponent().setName("value").setValue(new StringType(id))); + targetParam + .addPart(new ParametersParameterComponent().setName("count").setValue( + new StringType("1"))); + + final IParser iParser = r4Context.newJsonParser(); + return iParser.encodeResourceToString(param); + } + + @NotNull protected HttpHeaders getHttpHeaders(String gPasUserName, String gPasPassword) { var headers = new HttpHeaders(); diff --git a/src/main/java/dev/dnpm/etl/processor/pseudonym/PsnDomainType.java b/src/main/java/dev/dnpm/etl/processor/pseudonym/PsnDomainType.java new file mode 100644 index 0000000..a0fbc93 --- /dev/null +++ b/src/main/java/dev/dnpm/etl/processor/pseudonym/PsnDomainType.java @@ -0,0 +1,12 @@ +package dev.dnpm.etl.processor.pseudonym; + +public enum PsnDomainType { + /** + * one pseudonym per original value + */ + SINGLE_PSN_DOMAIN, + /** + * multiple pseudonymes for one original value + */ + MULTI_PSN_DOMAIN +} diff --git a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt index a2ea032..207785e 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/config/AppConfigProperties.kt @@ -48,7 +48,8 @@ data class PseudonymizeConfigProperties( @ConfigurationProperties(GPasConfigProperties.NAME) data class GPasConfigProperties( val uri: String?, - val target: String = "etl-processor", + val patientDomain: String = "etl-processor", + val genomDeTanDomain: String = "ccdn", val username: String?, val password: String?, ) { diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt index c03db12..0537cbb 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/AnonymizingGenerator.kt @@ -21,9 +21,12 @@ package dev.dnpm.etl.processor.pseudonym import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.digest.DigestUtils - +import java.security.SecureRandom class AnonymizingGenerator : Generator { + companion object fun getSecureRandom() : SecureRandom { + return SecureRandom() + } override fun generate(id: String): String { return Base32().encodeAsString(DigestUtils.sha256(id)) @@ -31,4 +34,14 @@ class AnonymizingGenerator : Generator { .lowercase() } + @OptIn(ExperimentalStdlibApi::class) + override fun generateGenomDeTan(id: String?): String { + + val bytes = ByteArray(64 / 2) + getSecureRandom().nextBytes(bytes) + + return bytes.joinToString("") { "%02x".format(it) } + + } + } \ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt index e80f6ec..96225a9 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeService.kt @@ -35,6 +35,10 @@ class PseudonymizeService( } } + fun genomDeTan(patientId: PatientId): String { + return generator.generateGenomDeTan(patientId.value) + } + fun prefix(): String { return configProperties.prefix } diff --git a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt index 77f3399..50d5b20 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/pseudonym/extensions.kt @@ -349,3 +349,8 @@ fun Mtb.ensureMetaDataIsInitialized() { this.metadata.modelProjectConsent.provisions.toMutableList() } } + +infix fun Mtb.addGenomDeTan(pseudonymizeService: PseudonymizeService) +{ + this.metadata.transferTan = pseudonymizeService.genomDeTan(PatientId(this.patient.id)) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/ConsentProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/ConsentProcessor.kt index 3841641..11aff57 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/ConsentProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/ConsentProcessor.kt @@ -38,7 +38,7 @@ class ConsentProcessor( /** * In case an instance of {@link ICheckConsent} is active, consent will be embedded and checked. * - * Logik: + * Logic: * * true IF consent check is disabled. * * true IF broad consent (BC) has been given. * * true BC has been asked AND declined but genomDe consent has been consented. diff --git a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt index bb226c0..f2e8390 100644 --- a/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt +++ b/src/main/kotlin/dev/dnpm/etl/processor/services/RequestProcessor.kt @@ -30,8 +30,11 @@ import dev.dnpm.etl.processor.monitoring.RequestStatus import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.output.* import dev.dnpm.etl.processor.pseudonym.PseudonymizeService +import dev.dnpm.etl.processor.pseudonym.addGenomDeTan import dev.dnpm.etl.processor.pseudonym.anonymizeContentWith import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith +import dev.pcvolkmer.mv64e.mtb.ConsentProvision +import dev.pcvolkmer.mv64e.mtb.ModelProjectConsentPurpose import dev.pcvolkmer.mv64e.mtb.Mtb import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.digest.DigestUtils @@ -76,9 +79,12 @@ class RequestProcessor( fun processMtbFile(mtbFile: Mtb, requestId: RequestId) { val pid = PatientId(extractPatientIdentifier(mtbFile)) - val isConsentOk = consentProcessor != null && - consentProcessor.consentGatedCheckAndTryEmbedding(mtbFile) || consentProcessor == null + val isConsentOk = + consentProcessor != null && consentProcessor.consentGatedCheckAndTryEmbedding(mtbFile) || consentProcessor == null if (isConsentOk) { + if (isGenomDeConsented(mtbFile)) { + mtbFile addGenomDeTan pseudonymizeService + } mtbFile pseudonymizeWith pseudonymizeService mtbFile anonymizeContentWith pseudonymizeService val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile)) @@ -93,6 +99,13 @@ class RequestProcessor( } } + private fun isGenomDeConsented(mtbFile: Mtb): Boolean { + val isModelProjectConsented = mtbFile.metadata?.modelProjectConsent?.provisions?.any { p -> + p.purpose == ModelProjectConsentPurpose.SEQUENCING && p.type == ConsentProvision.PERMIT + } == true + return isModelProjectConsented + } + private fun saveAndSend(request: MtbFileRequest, pid: PatientId) { requestService.save( Request( diff --git a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt index 4646ff6..819454f 100644 --- a/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt +++ b/src/test/kotlin/dev/dnpm/etl/processor/pseudonym/PseudonymizeServiceTest.kt @@ -71,8 +71,8 @@ class PseudonymizeServiceTest { } @Test - fun sanitizeFileName(@Mock generator: GpasPseudonymGenerator) { - val result= GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>") + fun sanitizeFileName() { + val result = GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>") assertThat(result).isEqualTo("l___a_bs_1_2_3_") } @@ -90,4 +90,16 @@ class PseudonymizeServiceTest { assertThat(mtbFile.patient.id).isEqualTo("UNKNOWN_123") } + @Test + fun shouldReturnDifferentValues() { + val ag = AnonymizingGenerator() + + val tans = HashSet() + + (1..1000).forEach { i -> + val tan = ag.generateGenomDeTan("12345") + assertThat(tan).hasSize(64) + assertThat(tans.add(tan)).`as`("never the same result!").isTrue + } + } } \ No newline at end of file