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:
33
README.md
33
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.
|
||||
|
||||

|
||||
|
||||
@@ -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).
|
||||
|
@@ -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
|
||||
)
|
||||
|
@@ -23,4 +23,6 @@ public interface Generator {
|
||||
|
||||
String generate(String id);
|
||||
|
||||
String generateGenomDeTan(String id);
|
||||
|
||||
}
|
||||
|
@@ -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();
|
||||
|
@@ -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
|
||||
}
|
@@ -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?,
|
||||
) {
|
||||
|
@@ -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) }
|
||||
|
||||
}
|
||||
|
||||
}
|
@@ -35,6 +35,10 @@ class PseudonymizeService(
|
||||
}
|
||||
}
|
||||
|
||||
fun genomDeTan(patientId: PatientId): String {
|
||||
return generator.generateGenomDeTan(patientId.value)
|
||||
}
|
||||
|
||||
fun prefix(): String {
|
||||
return configProperties.prefix
|
||||
}
|
||||
|
@@ -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))
|
||||
}
|
@@ -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.
|
||||
|
@@ -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(
|
||||
|
@@ -71,7 +71,7 @@ class PseudonymizeServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizeFileName(@Mock generator: GpasPseudonymGenerator) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user