1
0
mirror of https://github.com/pcvolkmer/mv64e-etl-processor synced 2025-09-13 09:02:50 +00:00

119 add transaction (#124)

This commit is contained in:
jlidke
2025-07-23 22:11:47 +02:00
committed by GitHub
parent 199511e567
commit dfc9de78ce
12 changed files with 172 additions and 38 deletions

View File

@@ -13,8 +13,7 @@ Duplikate werden verworfen, Änderungen werden weitergeleitet.
Löschanfragen werden immer als Löschanfrage an DNPM:DIP 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 Zudem ist eine minimalistische Weboberfläche integriert, die einen Einblick in den aktuellen Zustand der Anwendung gewährt.
der Anwendung gewährt.
![Modell DNPM-ETL-Strecke](docs/etl.png) ![Modell DNPM-ETL-Strecke](docs/etl.png)
@@ -26,6 +25,18 @@ Konfigurationsparameter
### Modelvorhaben genomDE §64e ### 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 Um die voll Betriebsbereitschaft herzustellen, muss eine erfolgreiche Übertragung mit dem
Submission-Typ *Test* erfolgt sein. Über die Umgebungsvariable wird dieser Übertragungsmodus 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 aktiviert. Alle Datensätze mit erteilter Teilnahme am Modelvorhaben werden mit der Test-Kennung
@@ -98,20 +109,21 @@ vergleichbare IDs bereitzustellen.
#### Eingebaute Anonymisierung #### Eingebaute Anonymisierung
Wurde keine oder die Verwendung der eingebauten Anonymisierung konfiguriert, so wird für die Wurde keine oder die Verwendung der eingebauten Anonymisierung konfiguriert, so wird für die
Patienten-ID der Patienten-ID der entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende
entsprechende SHA-256-Hash gebildet und Base64-codiert - hier ohne endende "=" - zuzüglich des "=" - zuzüglich des konfigurierten Präfixes als Patienten-Pseudonym verwendet.
konfigurierten Präfixes
als Patienten-Pseudonym verwendet.
#### Pseudonymisierung mit gPAS #### 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. Ab Version 2025.1 (Multi-Pseudonym Support)
`http://localhost:8080/ttp-fhir/fhir/gpas/$$pseudonymizeAllowCreate`)
* `APP_PSEUDONYMIZE_GPAS_TARGET`: gPas Domänenname * `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_USERNAME`: gPas Basic-Auth Benutzername
* `APP_PSEUDONYMIZE_GPAS_PASSWORD`: gPas Basic-Auth Passwort * `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 ### (Externe) Consent-Services
@@ -173,8 +185,7 @@ Modelvorhaben §64e.
### Anmeldung mit einem Passwort ### Anmeldung mit einem Passwort
Ein initialer Administrator-Account kann optional konfiguriert werden und sorgt dafür, dass Ein initialer Administrator-Account kann optional konfiguriert werden und sorgt dafür, dass
bestimmte Bereiche nur nach bestimmte Bereiche nur nach einem erfolgreichen Login erreichbar sind.
einem erfolgreichen Login erreichbar sind.
* `APP_SECURITY_ADMIN_USER`: Muss angegeben werden zur Aktivierung der Zugriffsbeschränkung. * `APP_SECURITY_ADMIN_USER`: Muss angegeben werden zur Aktivierung der Zugriffsbeschränkung.
* `APP_SECURITY_ADMIN_PASSWORD`: Das Passwort für den Administrator (Empfohlen). * `APP_SECURITY_ADMIN_PASSWORD`: Das Passwort für den Administrator (Empfohlen).

View File

@@ -49,8 +49,8 @@ class GpasPseudonymGeneratorTest {
fun setup() { fun setup() {
val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build() val retryTemplate = RetryTemplateBuilder().customPolicy(SimpleRetryPolicy(1)).build()
val gPasConfigProperties = GPasConfigProperties( val gPasConfigProperties = GPasConfigProperties(
"https://localhost/ttp-fhir/fhir/gpas/\$pseudonymizeAllowCreate", "https://localhost:9990/ttp-fhir/fhir/gpas",
"test", "test", "test2",
null, null,
null null
) )

View File

@@ -23,4 +23,6 @@ public interface Generator {
String generate(String id); String generate(String id);
String generateGenomDeTan(String id);
} }

View File

@@ -23,7 +23,11 @@ import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.parser.IParser;
import dev.dnpm.etl.processor.config.AppFhirConfig; import dev.dnpm.etl.processor.config.AppFhirConfig;
import dev.dnpm.etl.processor.config.GPasConfigProperties; 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.commons.lang3.StringUtils;
import org.apache.hc.core5.net.URIBuilder;
import org.hl7.fhir.r4.model.Identifier; import org.hl7.fhir.r4.model.Identifier;
import org.hl7.fhir.r4.model.Parameters; import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent; import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
@@ -42,38 +46,67 @@ public class GpasPseudonymGenerator implements Generator {
private final FhirContext r4Context; private final FhirContext r4Context;
private final String gPasUrl; private final String gPasUrl;
private final String psnTargetDomain;
private final HttpHeaders httpHeader; private final HttpHeaders httpHeader;
private final RetryTemplate retryTemplate; private final RetryTemplate retryTemplate;
private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class); private final Logger log = LoggerFactory.getLogger(GpasPseudonymGenerator.class);
private final RestTemplate restTemplate; 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, public GpasPseudonymGenerator(GPasConfigProperties gpasCfg, RetryTemplate retryTemplate,
RestTemplate restTemplate, AppFhirConfig appFhirConfig) { RestTemplate restTemplate, AppFhirConfig appFhirConfig) {
this.retryTemplate = retryTemplate; this.retryTemplate = retryTemplate;
this.restTemplate = restTemplate; this.restTemplate = restTemplate;
this.gPasUrl = gpasCfg.getUri(); this.gPasUrl = gpasCfg.getUri();
this.psnTargetDomain = gpasCfg.getTarget(); this.pidPsnDomain = gpasCfg.getPatientDomain();
this.genomDeTanDomain = gpasCfg.getGenomDeTanDomain();
this.r4Context = appFhirConfig.fhirContext(); this.r4Context = appFhirConfig.fhirContext();
httpHeader = getHttpHeaders(gpasCfg.getUsername(), gpasCfg.getPassword()); 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 @Override
public String generate(String id) { public String generate(String id) {
var gPasRequestBody = getGpasRequestBody(id); return generate(id, PsnDomainType.SINGLE_PSN_DOMAIN);
var responseEntity = getGpasPseudonym(gPasRequestBody); }
var gPasPseudonymResult = (Parameters) r4Context.newJsonParser()
@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()); .parseResource(responseEntity.getBody());
return unwrapPseudonym(gPasPseudonymResult); 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 @NotNull
public static String unwrapPseudonym(Parameters gPasPseudonymResult) { public static String unwrapPseudonym(Parameters gPasPseudonymResult, String targetPartName) {
final var parameters = gPasPseudonymResult.getParameter().stream().findFirst(); final var parameters = gPasPseudonymResult.getParameter().stream().findFirst();
if (parameters.isEmpty()) { if (parameters.isEmpty()) {
@@ -81,7 +114,7 @@ public class GpasPseudonymGenerator implements Generator {
} }
final var identifier = (Identifier) parameters.get().getPart().stream() final var identifier = (Identifier) parameters.get().getPart().stream()
.filter(a -> a.getName().equals("pseudonym")) .filter(a -> a.getName().equals(targetPartName))
.findFirst() .findFirst()
.orElseGet(ParametersParameterComponent::new).getValue(); .orElseGet(ParametersParameterComponent::new).getValue();
@@ -104,13 +137,14 @@ public class GpasPseudonymGenerator implements Generator {
} }
@NotNull @NotNull
protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody) { protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody, String apiEndpoint) {
HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader); HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader);
try { try {
var targetUrl = buildRequestUrl(apiEndpoint);
ResponseEntity<String> responseEntity = retryTemplate.execute( ResponseEntity<String> responseEntity = retryTemplate.execute(
ctx -> restTemplate.exchange(gPasUrl, HttpMethod.POST, requestEntity, ctx -> restTemplate.exchange(targetUrl, HttpMethod.POST, requestEntity,
String.class)); String.class));
if (responseEntity.getStatusCode().is2xxSuccessful()) { if (responseEntity.getStatusCode().is2xxSuccessful()) {
log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode()); log.debug("API request succeeded. Response: {}", responseEntity.getStatusCode());
@@ -139,16 +173,43 @@ public class GpasPseudonymGenerator implements Generator {
} }
protected String getGpasRequestBody(String id) { protected URI buildRequestUrl(String apiEndpoint) throws URISyntaxException {
var requestParameters = new Parameters(); 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") requestParameters.addParameter().setName("target")
.setValue(new StringType().setValue(psnTargetDomain)); .setValue(new StringType().setValue(targetDomain));
requestParameters.addParameter().setName("original") requestParameters.addParameter().setName("original")
.setValue(new StringType().setValue(id)); .setValue(new StringType().setValue(id));
final IParser iParser = r4Context.newJsonParser(); final IParser iParser = r4Context.newJsonParser();
return iParser.encodeResourceToString(requestParameters); 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 @NotNull
protected HttpHeaders getHttpHeaders(String gPasUserName, String gPasPassword) { protected HttpHeaders getHttpHeaders(String gPasUserName, String gPasPassword) {
var headers = new HttpHeaders(); var headers = new HttpHeaders();

View File

@@ -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
}

View File

@@ -48,7 +48,8 @@ data class PseudonymizeConfigProperties(
@ConfigurationProperties(GPasConfigProperties.NAME) @ConfigurationProperties(GPasConfigProperties.NAME)
data class GPasConfigProperties( data class GPasConfigProperties(
val uri: String?, val uri: String?,
val target: String = "etl-processor", val patientDomain: String = "etl-processor",
val genomDeTanDomain: String = "ccdn",
val username: String?, val username: String?,
val password: String?, val password: String?,
) { ) {

View File

@@ -21,9 +21,12 @@ package dev.dnpm.etl.processor.pseudonym
import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.binary.Base32
import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.codec.digest.DigestUtils
import java.security.SecureRandom
class AnonymizingGenerator : Generator { class AnonymizingGenerator : Generator {
companion object fun getSecureRandom() : SecureRandom {
return SecureRandom()
}
override fun generate(id: String): String { override fun generate(id: String): String {
return Base32().encodeAsString(DigestUtils.sha256(id)) return Base32().encodeAsString(DigestUtils.sha256(id))
@@ -31,4 +34,14 @@ class AnonymizingGenerator : Generator {
.lowercase() .lowercase()
} }
@OptIn(ExperimentalStdlibApi::class)
override fun generateGenomDeTan(id: String?): String {
val bytes = ByteArray(64 / 2)
getSecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
} }

View File

@@ -35,6 +35,10 @@ class PseudonymizeService(
} }
} }
fun genomDeTan(patientId: PatientId): String {
return generator.generateGenomDeTan(patientId.value)
}
fun prefix(): String { fun prefix(): String {
return configProperties.prefix return configProperties.prefix
} }

View File

@@ -349,3 +349,8 @@ fun Mtb.ensureMetaDataIsInitialized() {
this.metadata.modelProjectConsent.provisions.toMutableList() this.metadata.modelProjectConsent.provisions.toMutableList()
} }
} }
infix fun Mtb.addGenomDeTan(pseudonymizeService: PseudonymizeService)
{
this.metadata.transferTan = pseudonymizeService.genomDeTan(PatientId(this.patient.id))
}

View File

@@ -38,7 +38,7 @@ class ConsentProcessor(
/** /**
* In case an instance of {@link ICheckConsent} is active, consent will be embedded and checked. * In case an instance of {@link ICheckConsent} is active, consent will be embedded and checked.
* *
* Logik: * Logic:
* * <c>true</c> IF consent check is disabled. * * <c>true</c> IF consent check is disabled.
* * <c>true</c> IF broad consent (BC) has been given. * * <c>true</c> IF broad consent (BC) has been given.
* * <c>true</c> BC has been asked AND declined but genomDe consent has been consented. * * <c>true</c> BC has been asked AND declined but genomDe consent has been consented.

View File

@@ -30,8 +30,11 @@ import dev.dnpm.etl.processor.monitoring.RequestStatus
import dev.dnpm.etl.processor.monitoring.RequestType import dev.dnpm.etl.processor.monitoring.RequestType
import dev.dnpm.etl.processor.output.* import dev.dnpm.etl.processor.output.*
import dev.dnpm.etl.processor.pseudonym.PseudonymizeService 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.anonymizeContentWith
import dev.dnpm.etl.processor.pseudonym.pseudonymizeWith 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 dev.pcvolkmer.mv64e.mtb.Mtb
import org.apache.commons.codec.binary.Base32 import org.apache.commons.codec.binary.Base32
import org.apache.commons.codec.digest.DigestUtils import org.apache.commons.codec.digest.DigestUtils
@@ -76,9 +79,12 @@ class RequestProcessor(
fun processMtbFile(mtbFile: Mtb, requestId: RequestId) { fun processMtbFile(mtbFile: Mtb, requestId: RequestId) {
val pid = PatientId(extractPatientIdentifier(mtbFile)) val pid = PatientId(extractPatientIdentifier(mtbFile))
val isConsentOk = consentProcessor != null && val isConsentOk =
consentProcessor.consentGatedCheckAndTryEmbedding(mtbFile) || consentProcessor == null consentProcessor != null && consentProcessor.consentGatedCheckAndTryEmbedding(mtbFile) || consentProcessor == null
if (isConsentOk) { if (isConsentOk) {
if (isGenomDeConsented(mtbFile)) {
mtbFile addGenomDeTan pseudonymizeService
}
mtbFile pseudonymizeWith pseudonymizeService mtbFile pseudonymizeWith pseudonymizeService
mtbFile anonymizeContentWith pseudonymizeService mtbFile anonymizeContentWith pseudonymizeService
val request = DnpmV2MtbFileRequest(requestId, transformationService.transform(mtbFile)) 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 <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) { private fun <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
requestService.save( requestService.save(
Request( Request(

View File

@@ -71,7 +71,7 @@ class PseudonymizeServiceTest {
} }
@Test @Test
fun sanitizeFileName(@Mock generator: GpasPseudonymGenerator) { fun sanitizeFileName() {
val result = GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>") val result = GpasPseudonymGenerator.sanitizeValue("l://a\\bs;1*2?3>")
assertThat(result).isEqualTo("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") assertThat(mtbFile.patient.id).isEqualTo("UNKNOWN_123")
} }
@Test
fun shouldReturnDifferentValues() {
val ag = AnonymizingGenerator()
val tans = HashSet<String>()
(1..1000).forEach { i ->
val tan = ag.generateGenomDeTan("12345")
assertThat(tan).hasSize(64)
assertThat(tans.add(tan)).`as`("never the same result!").isTrue
}
}
} }