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.
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.
* `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).

View File

@@ -49,8 +49,8 @@ class GpasPseudonymGeneratorTest {
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
)

View File

@@ -23,4 +23,6 @@ public interface Generator {
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 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()
return generate(id, PsnDomainType.SINGLE_PSN_DOMAIN);
}
@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);
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<String> getGpasPseudonym(String gPasRequestBody) {
protected ResponseEntity<String> getGpasPseudonym(String gPasRequestBody, String apiEndpoint) {
HttpEntity<String> requestEntity = new HttpEntity<>(gPasRequestBody, this.httpHeader);
try {
var targetUrl = buildRequestUrl(apiEndpoint);
ResponseEntity<String> 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();

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)
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?,
) {

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ class ConsentProcessor(
/**
* 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 broad consent (BC) has been given.
* * <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.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 <T> saveAndSend(request: MtbFileRequest<T>, pid: PatientId) {
requestService.save(
Request(

View File

@@ -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<String>()
(1..1000).forEach { i ->
val tan = ag.generateGenomDeTan("12345")
assertThat(tan).hasSize(64)
assertThat(tans.add(tan)).`as`("never the same result!").isTrue
}
}
}