Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release 4.3.0 #256

Merged
merged 16 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [UNRELEASED] - yyyy-mm-dd
## [4.3.0] - 2024-02-02

### Added
- Basic auth for direct broker ([#210](https://github.com/medizininformatik-initiative/feasibility-backend/issues/210))
### Changed
### Deprecated
### Removed
### Fixed
- Updated sq2cql to 0.2.14 ([#253](https://github.com/medizininformatik-initiative/feasibility-backend/issues/253))
- Reduce verbosity of DSF Webservice client ([#247](https://github.com/medizininformatik-initiative/feasibility-backend/issues/247))
### Security

The full changelog can be found [here](https://todo).
- Updated spring boot to 3.2.2 ([#251](https://github.com/medizininformatik-initiative/feasibility-backend/issues/251))

## [4.2.0] - 2023-11-17

Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
| QUERY_VALIDATION_ENABLED | When enabled, any structured query submitted via the `run-query` endpoint is validated against the JSON schema located in `src/main/resources/query/query-schema.json` | true / false | true |
| QUERYRESULT_EXPIRY_MINUTES | How many minutes should query results be kept in memory? | | 5 |
| QUERYRESULT_PUBLIC_KEY | The public key in Base64-encoded DER format without banners and line breaks. Mandatory if _QUERYRESULT_DISABLE_LOG_FILE_ENCRYPTION_ is _false_ |
| QUERYRESULT_DISABLE_LOG_FILE_ENCRYPTION | Disable encryption of the result log file. | true / false | |
| QUERYRESULT_DISABLE_LOG_FILE_ENCRYPTION | Disable encryption of the result log file. | true / false | |
| ALLOWED_ORIGINS | Allowed origins for cross-origin requests. This should at least cover the frontend address. | | http://localhost |
| MAX_SAVED_QUERIES_PER_USER | How many slots does a user have to store saved queries. | | 10 |

Expand All @@ -45,10 +45,12 @@ The DIRECT path can be run **either** with FLARE **or** with a CQL compatible se
Result counts from the direct path can be obfuscated for privacy reasons. The current implementation
handles obfuscation by adding or subtracting a random number <=5.

| EnvVar | Description | Example | Default |
|--------------------------------------|--------------------------------------------------------------------------------|---------|---------|
| BROKER_CLIENT_DIRECT_USE_CQL | Whether to use a CQL server or not. | | false |
| BROKER_CLIENT_OBFUSCATE_RESULT_COUNT | Whether the result counts retrieved from the direct broker shall be obfuscated | | false |
| EnvVar | Description | Example | Default |
|-------------------------------------------|--------------------------------------------------------------------------------|--------------------|---------|
| BROKER_CLIENT_DIRECT_AUTH_BASIC_USERNAME | Username to use to connect to flare or directly to the FHIR server via CQL | feas-user | |
| BROKER_CLIENT_DIRECT_AUTH_BASIC_PASSWORD | Password for that user | verysecurepassword | |
| BROKER_CLIENT_DIRECT_USE_CQL | Whether to use a CQL server or not. | | false |
| BROKER_CLIENT_OBFUSCATE_RESULT_COUNT | Whether the result counts retrieved from the direct broker shall be obfuscated | | false |

This is irrelevant if _BROKER_CLIENT_DIRECT_ENABLED_ is set to false.

Expand Down Expand Up @@ -92,10 +94,10 @@ In order to run the backend using the DSF path, the following environment variab
| DSF_PROXY_USERNAME | Proxy username to be used. | | |
| DSF_PROXY_PASSWORD | Proxy password to be used. | | |
| DSF_WEBSERVICE_BASE_URL | Base URL pointing to the local ZARS FHIR server. | `https://zars/fhir` | |
| DSF_WEBSERVICE_LOG_REQUESTS | Log webservice client communication at log level INFO or below (**WARNING**: potentially contains sensitive data) | `true` | `false` |
| DSF_WEBSOCKET_URL | URL pointing to the local ZARS FHIR server websocket endpoint. | `wss://zars/fhir/ws` | |
| DSF_ORGANIZATION_ID | Identifier for the local organization this backend is part of. | `MY ZARS` | |


### Privacy and Obfuscation

In order to prevent potentially malicious attempts to obtain critical patient data, several
Expand Down
11 changes: 11 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ services:
QUERYRESULT_EXPIRY_MINUTES: ${CODEX_FEASIBILITY_BACKEND_QUERYRESULT_EXPIRY_MINUTES:-5}
MAX_SAVED_QUERIES_PER_USER: ${CODEX_FEASIBILITY_BACKEND_MAX_SAVED_QUERIES_PER_USER:-10}
# ---- Direct
BROKER_CLIENT_DIRECT_AUTH_BASIC_USERNAME: ${CODEX_FEASIBILITY_BACKEND_BROKER_CLIENT_DIRECT_AUTH_BASIC_USERNAME}
BROKER_CLIENT_DIRECT_AUTH_BASIC_PASSWORD: ${CODEX_FEASIBILITY_BACKEND_BROKER_CLIENT_DIRECT_AUTH_BASIC_PASSWORD}
BROKER_CLIENT_DIRECT_USE_CQL: ${CODEX_FEASIBILITY_BACKEND_BROKER_CLIENT_DIRECT_USE_CQL:-false}
BROKER_CLIENT_OBFUSCATE_RESULT_COUNT: ${CODEX_FEASIBILITY_BACKEND_BROKER_CLIENT_OBFUSCATE_RESULT_COUNT:-false}
# ---- Aktin
Expand Down Expand Up @@ -65,6 +67,7 @@ services:
PRIVACY_THRESHOLD_RESULTS: ${CODEX_FEASIBILITY_BACKEND_PRIVACY_THRESHOLD_RESULTS}
PRIVACY_THRESHOLD_SITES: ${CODEX_FEASIBILITY_BACKEND_PRIVACY_THRESHOLD_SITES}
PRIVACY_THRESHOLD_SITES_RESULT: ${CODEX_FEASIBILITY_BACKEND_PRIVACY_THRESHOLD_SITES_RESULT}
QUERYRESULT_DISABLE_LOG_FILE_ENCRYPTION: "false"
volumes:
- ${CODEX_FEASIBILITY_BACKEND_LOCAL_CONCEPT_TREE_PATH:-./ontology/codex-code-tree.json}:${CODEX_FEASIBILITY_BACKEND_ONTOLOGY_FILES_FOLDER:-/opt/codex-feasibility-backend/ontology}/codex-code-tree.json
- ${CODEX_FEASIBILITY_BACKEND_LOCAL_TERM_CODE_MAPPING_PATH:-./ontology/codex-term-code-mapping.json}:${CODEX_FEASIBILITY_BACKEND_ONTOLOGY_FILES_FOLDER:-/opt/codex-feasibility-backend/ontology}/codex-term-code-mapping.json
Expand All @@ -79,3 +82,11 @@ services:
- POSTGRES_USER=codex-postgres
- POSTGRES_PASSWORD=codex-password
- POSTGRES_DB=codex_ui
volumes:
- type: volume
source: feas-backend-db-data
target: /var/lib/postgresql/data

volumes:
feas-backend-db-data:
name: "feas-backend-db-data"
6 changes: 3 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<version>3.2.2</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>

<groupId>de.medizininformatik-initiative</groupId>
<artifactId>FeasibilityGuiBackend</artifactId>
<version>4.2.0</version>
<version>4.3.0</version>

<name>FeasibilityGuiBackend</name>
<description>Backend of the Feasibility GUI</description>
Expand Down Expand Up @@ -198,7 +198,7 @@
<dependency>
<groupId>de.medizininformatik-initiative</groupId>
<artifactId>sq2cql</artifactId>
<version>0.2.4</version>
<version>0.2.14</version>
</dependency>

<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package de.numcodex.feasibility_gui_backend.query.broker.direct;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IClientInterceptor;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.interceptor.BasicAuthInterceptor;
import de.numcodex.feasibility_gui_backend.query.broker.BrokerClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
Expand All @@ -10,6 +12,8 @@
import org.springframework.context.annotation.Lazy;
import org.springframework.web.reactive.function.client.WebClient;

import static org.springframework.web.reactive.function.client.ExchangeFilterFunctions.basicAuthentication;

/**
* Spring configuration for providing a {@link DirectBrokerClient} implementation instance.
* Either {@link DirectBrokerClientCql} or {@link DirectBrokerClientFlare}
Expand All @@ -18,36 +22,55 @@
@Configuration
public class DirectSpringConfig {

@Value("${app.broker.direct.useCql:false}")
private boolean useCql;
private final boolean useCql;

private final String flareBaseUrl;

private final String cqlBaseUrl;

@Value("${app.flare.baseUrl}")
private String flareBaseUrl;
private final String username;

@Value("${app.cql.baseUrl}")
private String cqlBaseUrl;
private final String password;

public DirectSpringConfig(@Value("${app.broker.direct.useCql:false}") boolean useCql, @Value("${app.flare.baseUrl}") String flareBaseUrl, @Value("${app.cql.baseUrl}") String cqlBaseUrl, @Value("${app.broker.direct.auth.basic.username}") String username, @Value("${app.broker.direct.auth.basic.password}") String password) {
this.useCql = useCql;
this.flareBaseUrl = flareBaseUrl;
this.cqlBaseUrl = cqlBaseUrl;
this.username = username;
this.password = password;
}

@Qualifier("direct")
@Bean
public BrokerClient directBrokerClient(WebClient directWebClientFlare, @Value("${app.broker.direct.obfuscateResultCount:false}") boolean obfuscateResultCount,
FhirConnector fhirConnector, FhirHelper fhirHelper) {
if (useCql) {

return new DirectBrokerClientCql(fhirConnector, obfuscateResultCount,
fhirHelper);
return new DirectBrokerClientCql(fhirConnector, obfuscateResultCount, fhirHelper);
} else {
return new DirectBrokerClientFlare(directWebClientFlare, obfuscateResultCount);
}
}

@Bean
public IGenericClient getFhirClient(FhirContext fhirContext){
return fhirContext.newRestfulGenericClient(cqlBaseUrl);
public IGenericClient getFhirClient(FhirContext fhirContext) {
IGenericClient iGenericClient = fhirContext.newRestfulGenericClient(cqlBaseUrl);
if (username != null && password != null && !username.isEmpty() && !password.isEmpty()) {
IClientInterceptor authInterceptor = new BasicAuthInterceptor(username, password);
iGenericClient.registerInterceptor(authInterceptor);
}
return iGenericClient;
}

@Bean
public WebClient directWebClientFlare() {
return WebClient.create(flareBaseUrl);
if (username != null && password != null && !username.isEmpty() && !password.isEmpty()) {
return WebClient.builder()
.filter(basicAuthentication(username, password))
.baseUrl(flareBaseUrl)
.build();
} else {
return WebClient.create(flareBaseUrl);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,22 @@ class DSFFhirWebClientProvider implements FhirWebClientProvider {
private final FhirSecurityContextProvider securityContextProvider;
private FhirSecurityContext securityContext;
private final FhirProxyContext proxyContext;
private boolean logRequests;


public DSFFhirWebClientProvider(FhirContext fhirContext, String webserviceBaseUrl, int webserviceReadTimeout,
int webserviceConnectTimeout, String websocketUrl,
FhirSecurityContextProvider securityContextProvider,
FhirProxyContext proxyContext) {
FhirProxyContext proxyContext,
boolean logRequests) {
this.fhirContext = fhirContext;
this.webserviceBaseUrl = webserviceBaseUrl;
this.webserviceReadTimeout = webserviceReadTimeout;
this.webserviceConnectTimeout = webserviceConnectTimeout;
this.websocketUrl = websocketUrl;
this.securityContextProvider = securityContextProvider;
this.proxyContext = proxyContext;
this.logRequests = logRequests;
}

@Override
Expand All @@ -76,7 +79,7 @@ public FhirWebserviceClient provideFhirWebserviceClient() throws FhirWebClientPr
proxyContext.getPassword(),
webserviceConnectTimeout,
webserviceReadTimeout,
true,
logRequests,
null,
fhirContext,
cleaner);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ public class DSFSpringConfig {
@Value("${app.broker.dsf.webservice.connectTimeout}")
private int webserviceConnectTimeout;

@Value("${app.broker.dsf.webservice.logRequests}")
private boolean logRequests;

@Value("${app.broker.dsf.websocket.url}")
private String websocketUrl;

Expand Down Expand Up @@ -102,7 +105,7 @@ FhirWebClientProvider fhirWebClientProvider(FhirContext fhirContext,
FhirSecurityContextProvider securityContextProvider,
FhirProxyContext proxyContext) {
return new DSFFhirWebClientProvider(fhirContext, webserviceBaseUrl, webserviceReadTimeout,
webserviceConnectTimeout, websocketUrl, securityContextProvider, proxyContext);
webserviceConnectTimeout, websocketUrl, securityContextProvider, proxyContext, logRequests);
}

}
5 changes: 5 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ app:
mock:
enabled: ${BROKER_CLIENT_MOCK_ENABLED:false}
direct:
auth:
basic:
username: ${BROKER_CLIENT_DIRECT_AUTH_BASIC_USERNAME:}
password: ${BROKER_CLIENT_DIRECT_AUTH_BASIC_PASSWORD:}
enabled: ${BROKER_CLIENT_DIRECT_ENABLED:false}
useCql: ${BROKER_CLIENT_DIRECT_USE_CQL:false}
obfuscateResultCount: ${BROKER_CLIENT_OBFUSCATE_RESULT_COUNT:false}
Expand All @@ -79,6 +83,7 @@ app:
baseUrl: ${DSF_WEBSERVICE_BASE_URL}
readTimeout: 20000
connectTimeout: 2000
logRequests: ${DSF_WEBSERVICE_LOG_REQUESTS:false}
websocket:
url: ${DSF_WEBSOCKET_URL}
organizationId: ${DSF_ORGANIZATION_ID}
Expand Down
6 changes: 5 additions & 1 deletion src/main/resources/static/v3/api-docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -823,9 +823,13 @@ components:
format: int64
label:
type: string
created_at:
comment:
type: string
createdAt:
type: string
format: 'date-time'
totalNumberOfPatients:
type: integer
Query:
type: object
required:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package de.numcodex.feasibility_gui_backend.query.broker.direct;

import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

import static org.assertj.core.api.Assertions.assertThat;


@ExtendWith(MockitoExtension.class)
public class DirectSpringConfigIT {

private static final String USERNAME = "some-user-123";
private static final String PASSWORD = "vALBAi95WW84x3";
MockWebServer mockWebServer;

private DirectSpringConfig directSpringConfig;

@BeforeEach
void setUp() throws IOException {
mockWebServer = new MockWebServer();
mockWebServer.start();
}

@AfterEach
void tearDown() throws IOException {
mockWebServer.shutdown();
}

@Test
void testDirectWebClientFlare_withCredentials() throws InterruptedException {
mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody("Foo"));
directSpringConfig = new DirectSpringConfig(true, String.format("http://localhost:%s", mockWebServer.getPort()), null, USERNAME, PASSWORD);
var authHeaderValue = "Basic " + Base64.getEncoder().encodeToString((USERNAME + ":" + PASSWORD).getBytes(StandardCharsets.UTF_8));

WebClient webClient = directSpringConfig.directWebClientFlare();

webClient
.get()
.uri("/foo")
.retrieve()
.bodyToMono(String.class)
.subscribe(responseBody -> {
})
;
var recordedRequest = mockWebServer.takeRequest();
assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo(authHeaderValue);
}

@Test
void testDirectWebClientFlare_withoutCredentials() throws InterruptedException {
mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody("Foo"));
directSpringConfig = new DirectSpringConfig(true, String.format("http://localhost:%s", mockWebServer.getPort()), null, null, null);

WebClient webClient = directSpringConfig.directWebClientFlare();

webClient
.get()
.uri("/foo")
.retrieve()
.bodyToMono(String.class)
.subscribe(responseBody -> {
})
;
var recordedRequest = mockWebServer.takeRequest();
assertThat(recordedRequest.getHeader(HttpHeaders.AUTHORIZATION)).isNull();
}

}
Loading
Loading