diff --git a/Dockerfile b/Dockerfile
index 6f03d4c0b1c..d79c01c6f96 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -35,6 +35,8 @@ COPY --from=build-hapi --chown=1001:1001 /tmp/hapi-fhir-jpaserver-starter/opente
ENV ALLOW_EMPTY_PASSWORD=yes
+FROM busybox:1.35.0-uclibc as busybox
+
########### distroless brings focus on security and runs on plain spring boot - this is the default image
FROM gcr.io/distroless/java17-debian11:nonroot AS default
# 65532 is the nonroot user's uid
@@ -45,5 +47,8 @@ WORKDIR /app
COPY --chown=nonroot:nonroot --from=build-distroless /app /app
COPY --chown=nonroot:nonroot --from=build-hapi /tmp/hapi-fhir-jpaserver-starter/opentelemetry-javaagent.jar /app
+COPY --chown=nonroot:nonroot --from=busybox /bin/sh /bin/sh
+COPY --chown=nonroot:nonroot --from=busybox /bin/cat /bin/cat
+COPY --chown=nonroot:nonroot entrypoint.sh /entrypoint.sh
-ENTRYPOINT ["java", "--class-path", "/app/main.war", "-Dloader.path=main.war!/WEB-INF/classes/,main.war!/WEB-INF/,/app/extra-classes", "org.springframework.boot.loader.PropertiesLauncher"]
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/entrypoint.sh b/entrypoint.sh
new file mode 100755
index 00000000000..c2e9d8ad22e
--- /dev/null
+++ b/entrypoint.sh
@@ -0,0 +1,9 @@
+#!/bin/sh
+
+export ELASTICSEARCH_PASSWORD=${ELASTICSEARCH_PASSWORD:=`cat ${ELASTICSEARCH_PASSWORD_FILE}`}
+export HAPI_DATASOURCE_PASSWORD=${HAPI_DATASOURCE_PASSWORD:=`cat ${HAPI_DATASOURCE_PASSWORD_FILE}`}
+
+# Execute the Java application
+java --class-path "/app/main.war" \
+-Dloader.path="main.war!/WEB-INF/classes/,main.war!/WEB-INF/,/app/extra-classes" \
+org.springframework.boot.loader.PropertiesLauncher "$@"
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 1b8fee45e9a..4b8fb9f1670 100644
--- a/pom.xml
+++ b/pom.xml
@@ -403,6 +403,17 @@
${logback-classic.version}
+
+ com.auth0
+ jwks-rsa
+ 0.22.1
+
+
+ com.auth0
+ java-jwt
+ 4.4.0
+
+
diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java
index 451c19dbb1b..4ed4d46c4bc 100644
--- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java
+++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java
@@ -85,6 +85,7 @@ public class AppProperties {
private Boolean lastn_enabled = false;
private boolean store_resource_in_lucene_index_enabled = false;
+ private String elasticsearch_index_prefix = "";
private NormalizedQuantitySearchLevel normalized_quantity_search_level = NormalizedQuantitySearchLevel.NORMALIZED_QUANTITY_SEARCH_NOT_SUPPORTED;
private Boolean use_apache_address_strategy = false;
@@ -94,6 +95,8 @@ public class AppProperties {
private Integer bundle_batch_pool_max_size = 100;
private final Set local_base_urls = new HashSet<>();
private final Set logical_urls = new HashSet<>();
+
+ private Oauth2 oauth2 = new Oauth2();
private final List custom_interceptor_classes = new ArrayList<>();
@@ -560,6 +563,14 @@ public void setStore_resource_in_lucene_index_enabled(Boolean store_resource_in_
this.store_resource_in_lucene_index_enabled = store_resource_in_lucene_index_enabled;
}
+ public String getElasticsearch_index_prefix() {
+ return elasticsearch_index_prefix;
+ }
+
+ public void setElasticsearch_index_prefix(String elasticsearch_index_prefix) {
+ this.elasticsearch_index_prefix = elasticsearch_index_prefix;
+ }
+
public NormalizedQuantitySearchLevel getNormalized_quantity_search_level() {
return this.normalized_quantity_search_level;
}
@@ -879,4 +890,43 @@ public boolean getEnable_index_of_type() {
public void setEnable_index_of_type(boolean enable_index_of_type) {
this.enable_index_of_type = enable_index_of_type;
}
+
+ public static class Oauth2 {
+
+ public Boolean enabled = false;
+ public String issuer = "";
+ public String jwks_uri = "";
+
+ public Boolean getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(Boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getIssuer() {
+ return issuer;
+ }
+
+ public void setIssuer(String issuer) {
+ this.issuer = issuer;
+ }
+
+ public String getJwks_uri() {
+ return jwks_uri;
+ }
+
+ public void setJwks_uri(String jwks_uri) {
+ this.jwks_uri = jwks_uri;
+ }
+ }
+
+ public Oauth2 getOauth2() {
+ return oauth2;
+ }
+
+ public void setOauth2(Oauth2 oauth2) {
+ this.oauth2 = oauth2;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java
index 403a0a5ae23..05326289a2c 100644
--- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java
+++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java
@@ -160,6 +160,10 @@ public JpaStorageSettings jpaStorageSettings(AppProperties appProperties) {
jpaStorageSettings.setInlineResourceTextBelowSize(appProperties.getInline_resource_storage_below_size());
}
+ if (appProperties.getElasticsearch_index_prefix() != null && !appProperties.getElasticsearch_index_prefix().isEmpty()) {
+ jpaStorageSettings.setHSearchIndexPrefix(appProperties.getElasticsearch_index_prefix());
+ }
+
jpaStorageSettings.setStoreResourceInHSearchIndex(appProperties.getStore_resource_in_lucene_index_enabled());
jpaStorageSettings.setNormalizedQuantitySearchLevel(appProperties.getNormalized_quantity_search_level());
jpaStorageSettings.setIndexOnContainedResources(appProperties.getEnable_index_contained_resource());
diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/interceptor/OAuth2AuthorizationInterceptor.java b/src/main/java/ca/uhn/fhir/jpa/starter/interceptor/OAuth2AuthorizationInterceptor.java
new file mode 100644
index 00000000000..77a4ca20616
--- /dev/null
+++ b/src/main/java/ca/uhn/fhir/jpa/starter/interceptor/OAuth2AuthorizationInterceptor.java
@@ -0,0 +1,264 @@
+package ca.uhn.fhir.jpa.starter.interceptor;
+
+import ca.uhn.fhir.context.FhirVersionEnum;
+import ca.uhn.fhir.jpa.starter.AppProperties;
+import ca.uhn.fhir.rest.api.server.RequestDetails;
+import ca.uhn.fhir.rest.server.interceptor.auth.AuthorizationInterceptor;
+import ca.uhn.fhir.rest.server.interceptor.auth.IAuthRule;
+import ca.uhn.fhir.rest.server.interceptor.auth.RuleBuilder;
+import com.auth0.jwk.JwkProvider;
+import com.auth0.jwk.JwkProviderBuilder;
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.JWTVerifier;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.auth0.jwt.interfaces.RSAKeyProvider;
+
+import org.hl7.fhir.instance.model.api.IBaseResource;
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.ArrayList;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import java.util.List;
+
+@Component
+public class OAuth2AuthorizationInterceptor extends AuthorizationInterceptor {
+
+ private static final Logger logger = LoggerFactory.getLogger(OAuth2AuthorizationInterceptor.class);
+ private final AppProperties appProperties;
+
+ public OAuth2AuthorizationInterceptor(AppProperties appProperties) {
+ this.appProperties = appProperties;
+ }
+
+ @Override
+ public List buildRuleList(RequestDetails theRequestDetails) {
+ if (!this.appProperties.getOauth2().getEnabled()) {
+ return new RuleBuilder().allowAll().build();
+ }
+ // Get Bearer token
+ String authHeader = theRequestDetails.getHeader("Authorization");
+ if (authHeader == null) {
+ return new RuleBuilder().denyAll().build();
+ }
+ String[] authHeaders = authHeader.split(" ");
+ if (authHeaders.length < 2) {
+ return new RuleBuilder().denyAll().build();
+ }
+ String token = authHeaders[1];
+ if (validateToken(token)) {
+ List fhirRoles = this.getFHIRRoles(token);
+ if (fhirRoles == null) {
+ return new RuleBuilder().denyAll().build();
+ }
+ if (fhirRoles.contains("FHIRAdmin")) {
+ return new RuleBuilder().allowAll().build();
+ }
+ List rules = new ArrayList<>();
+ if (fhirRoles.contains("FHIRTerminologyAdmin")) {
+ rules = this.addFHIRTerminologyAdminRules(rules);
+ }
+ if (fhirRoles.contains("FHIRCortexReadWrite")) {
+ rules = this.addFHIRCortexReadWriteRules(rules);
+ }
+ return rules;
+ }
+ return new RuleBuilder().denyAll().build();
+ }
+
+ private List getFHIRRoles(String token) {
+ DecodedJWT jwt = JWT.decode(token);
+ Map resourceAccess = jwt.getClaim("resource_access").asMap();
+ if (resourceAccess != null) {
+ // Assuming "fhir" is the client ID and you want to access its roles
+ Map fhirAccess = (Map) resourceAccess.get("fhir");
+
+ if (fhirAccess != null) {
+ // Extract the roles assigned within the "fhir" client
+ return (List) fhirAccess.get("roles");
+ }
+ }
+ return null;
+ }
+
+ private List addFHIRTerminologyAdminRules(List rules) {
+ List newRules = new ArrayList<>(rules);
+ FhirVersionEnum fhirVersion = this.appProperties.getFhir_version();
+ String fhirVersionName = fhirVersion.name();
+ newRules.addAll(new RuleBuilder()
+ .allow().write().resourcesOfType("CodeSystem").withAnyId().andThen()
+ .allow().read().resourcesOfType("CodeSystem").withAnyId().andThen()
+ .allow().delete().resourcesOfType("CodeSystem").withAnyId().andThen().build()
+ );
+ newRules.addAll(new RuleBuilder()
+ .allow().write().resourcesOfType("ValueSet").withAnyId().andThen()
+ .allow().read().resourcesOfType("ValueSet").withAnyId().andThen()
+ .allow().delete().resourcesOfType("ValueSet").withAnyId().andThen().build()
+ );
+ newRules.addAll(new RuleBuilder()
+ .allow().write().resourcesOfType("ConceptMap").withAnyId().andThen()
+ .allow().read().resourcesOfType("ConceptMap").withAnyId().andThen()
+ .allow().delete().resourcesOfType("ConceptMap").withAnyId().andThen().build()
+ );
+ newRules = this.addAllowReadAndAllOperationOfCodeSystem(newRules, fhirVersionName);
+ newRules = this.addAllowReadAndAllOperationOfValueSet(newRules, fhirVersionName);
+ newRules = this.addAllowReadAndAllOperationOfConceptMap(newRules, fhirVersionName);
+ return newRules;
+ }
+
+ private List addFHIRCortexReadWriteRules(List rules) {
+ List newRules = new ArrayList<>(rules);
+ FhirVersionEnum fhirVersion = this.appProperties.getFhir_version();
+ String fhirVersionName = fhirVersion.name();
+
+ newRules.addAll(new RuleBuilder()
+ .allow().write().resourcesOfType("Patient").withAnyId().andThen()
+ .allow().read().resourcesOfType("Patient").withAnyId().andThen()
+ .allow().delete().resourcesOfType("Patient").withAnyId().andThen().build()
+ );
+
+ newRules = this.addAllowReadAndAllOperationOfCodeSystem(newRules, fhirVersionName);
+ newRules = this.addAllowReadAndAllOperationOfValueSet(newRules, fhirVersionName);
+ newRules = this.addAllowReadAndAllOperationOfConceptMap(newRules, fhirVersionName);
+
+ return newRules;
+ }
+
+ private List addAllowReadAndAllOperationOfCodeSystem(List rules, String fhirVersionName) {
+ List newRules = new ArrayList<>(rules);
+ Class extends IBaseResource> codeSystemClass;
+ switch (fhirVersionName) {
+ case "R4":
+ codeSystemClass = org.hl7.fhir.r4.model.CodeSystem.class;
+ break;
+ case "R4B":
+ codeSystemClass = org.hl7.fhir.r4b.model.CodeSystem.class;
+ break;
+ case "R5":
+ codeSystemClass = org.hl7.fhir.r5.model.CodeSystem.class;
+ break;
+ default:
+ newRules.addAll(new RuleBuilder().denyAll().build());
+ return newRules;
+ }
+ newRules.addAll(new RuleBuilder()
+ .allow().read().resourcesOfType("CodeSystem").withAnyId().andThen()
+ .allow().operation().withAnyName().onType(codeSystemClass).andAllowAllResponses().andThen()
+ .build()
+ );
+ return newRules;
+ }
+
+ private List addAllowReadAndAllOperationOfValueSet(List rules, String fhirVersionName) {
+ List newRules = new ArrayList<>(rules);
+ Class extends IBaseResource> valueSetClass;
+ switch (fhirVersionName) {
+ case "R4":
+ valueSetClass = org.hl7.fhir.r4.model.ValueSet.class;
+ break;
+ case "R4B":
+ valueSetClass = org.hl7.fhir.r4b.model.ValueSet.class;
+ break;
+ case "R5":
+ valueSetClass = org.hl7.fhir.r5.model.ValueSet.class;
+ break;
+ default:
+ newRules.addAll(new RuleBuilder().denyAll().build());
+ return newRules;
+ }
+ newRules.addAll(new RuleBuilder()
+ .allow().read().resourcesOfType("ValueSet").withAnyId().andThen()
+ .allow().operation().withAnyName().onType(valueSetClass).andAllowAllResponses().andThen()
+ .build()
+ );
+ return newRules;
+ }
+
+ private List addAllowReadAndAllOperationOfConceptMap(List rules, String fhirVersionName) {
+ List newRules = new ArrayList<>(rules);
+ Class extends IBaseResource> conceptMapClass;
+ switch (fhirVersionName) {
+ case "R4":
+ conceptMapClass = org.hl7.fhir.r4.model.ConceptMap.class;
+ break;
+ case "R4B":
+ conceptMapClass = org.hl7.fhir.r4b.model.ConceptMap.class;
+ break;
+ case "R5":
+ conceptMapClass = org.hl7.fhir.r5.model.ConceptMap.class;
+ break;
+ default:
+ newRules.addAll(new RuleBuilder().denyAll().build());
+ return newRules;
+ }
+ newRules.addAll(new RuleBuilder()
+ .allow().read().resourcesOfType("ConceptMap").withAnyId().andThen()
+ .allow().operation().withAnyName().onType(conceptMapClass).andAllowAllResponses().andThen()
+ .build()
+ );
+ return newRules;
+ }
+
+ private boolean validateToken(String token) {
+ String jwksUrl = this.appProperties.getOauth2().getJwks_uri();
+ String issuer = this.appProperties.getOauth2().getIssuer();
+ try {
+ // Create a JwkProvider for the JWKS URL
+ JwkProvider provider = new JwkProviderBuilder(jwksUrl)
+ .cached(10, 24, TimeUnit.HOURS) // Cache up to 10 keys for 24 hours
+ .rateLimited(10, 1, TimeUnit.MINUTES) // Allow up to 10 requests per minute
+ .build();
+
+ Algorithm algorithm = getAlgorithm(provider);
+
+ // Prepare the verifier with the issuer and audience if necessary
+ JWTVerifier verifier = JWT.require(algorithm)
+ .withIssuer(issuer)
+ .build();
+
+ // Verify the token
+ verifier.verify(token);
+
+ // If no exception is thrown, the token is valid
+ return true;
+ } catch (Exception e) {
+ // Log or handle the exception as needed
+ logger.error(e.toString());
+ return false;
+ }
+ }
+
+ @NotNull
+ private static Algorithm getAlgorithm(JwkProvider provider) {
+ RSAKeyProvider keyProvider = new RSAKeyProvider() {
+ @Override
+ public RSAPublicKey getPublicKeyById(String keyId) {
+ try {
+ return (RSAPublicKey) provider.get(keyId).getPublicKey();
+ } catch (Exception e) {
+ throw new RuntimeException("Could not fetch the public key from JWKS", e);
+ }
+ }
+
+ @Override
+ public RSAPrivateKey getPrivateKey() {
+ return null; // Not used for token verification
+ }
+
+ @Override
+ public String getPrivateKeyId() {
+ return null; // Not used for token verification
+ }
+ };
+
+ // Prepare the algorithm with the key provider
+ return Algorithm.RSA256(keyProvider);
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 95ebfeca221..51be58b21a1 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -14,12 +14,10 @@ spring:
check-location: false
baselineOnMigrate: true
datasource:
- url: 'jdbc:h2:file:./target/database/h2'
- #url: jdbc:h2:mem:test_mem
- username: sa
- password: null
- driverClassName: org.h2.Driver
- max-active: 15
+ url: ${HAPI_DATASOURCE_URL}
+ username: ${HAPI_DATASOURCE_USERNAME}
+ password: ${HAPI_DATASOURCE_PASSWORD}
+ driverClassName: ${HAPI_DATASOURCE_DRIVER_CLASS_NAME}
# database connection pool size
hikari:
@@ -32,7 +30,7 @@ spring:
#Hibernate dialect is automatically detected except Postgres and H2.
#If using H2, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect
#If using postgres, then supply the value of ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgres94Dialect
- hibernate.dialect: ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect
+ hibernate.dialect: ${HAPI_HIBERNATE_DIALECT}
# hibernate.hbm2ddl.auto: update
# hibernate.jdbc.batch_size: 20
# hibernate.cache.use_query_cache: false
@@ -42,30 +40,29 @@ spring:
### These settings will enable fulltext search with lucene or elastic
hibernate.search.enabled: true
+ hibernate.search.backend.type: elasticsearch
+ hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer
### lucene parameters
# hibernate.search.backend.type: lucene
# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer
# hibernate.search.backend.directory.type: local-filesystem
# hibernate.search.backend.directory.root: target/lucenefiles
# hibernate.search.backend.lucene_version: lucene_current
- ### elastic parameters ===> see also elasticsearch section below <===
-# hibernate.search.backend.type: elasticsearch
-# hibernate.search.backend.analysis.configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer
hapi:
fhir:
### This flag when enabled to true, will avail evaluate measure operations from CR Module.
### Flag is false by default, can be passed as command line argument to override.
cr:
- enabled: false
+ enabled: "${CR_ENABLED: false}"
cdshooks:
- enabled: true
+ enabled: "${CDSHOOKS_ENABLED: false}"
clientIdHeaderName: client_id
### This enables the swagger-ui at /fhir/swagger-ui/index.html as well as the /fhir/api-docs (see https://hapifhir.io/hapi-fhir/docs/server_plain/openapi.html)
- openapi_enabled: true
+ openapi_enabled: "${OPENAPI_ENABLED: true}"
### This is the FHIR version. Choose between, DSTU2, DSTU3, R4 or R5
- fhir_version: R4
+ fhir_version: ${FHIR_VERSION}
### Flag is false by default. This flag enables runtime installation of IG's.
ig_runtime_upload_enabled: false
### This flag when enabled to true, will avail evaluate measure operations from CR Module.
@@ -169,7 +166,7 @@ hapi:
# comma-separated list of fully qualified interceptor classes.
# classes listed here will be fetched from the Spring context when combined with 'custom-bean-packages',
# or will be instantiated via reflection using an no-arg contructor; then registered with the server
- #custom-interceptor-classes:
+ custom-interceptor-classes: ca.uhn.fhir.jpa.starter.interceptor.OAuth2AuthorizationInterceptor
# Threadpool size for BATCH'ed GETs in a bundle.
# bundle_batch_pool_size: 10
@@ -193,7 +190,7 @@ hapi:
name: Local Tester
server_address: 'http://localhost:8080/fhir'
refuse_to_fetch_third_party_urls: false
- fhir_version: R4
+ fhir_version: ${FHIR_VERSION}
global:
name: Global Tester
server_address: "http://hapi.fhir.org/baseR4"
@@ -220,19 +217,24 @@ hapi:
# quitWait:
# lastn_enabled: true
# store_resource_in_lucene_index_enabled: true
+ elasticsearch_index_prefix: ${ELASTICSEARCH_INDEX_PREFIX}
### This is configuration for normalized quantity search level default is 0
### 0: NORMALIZED_QUANTITY_SEARCH_NOT_SUPPORTED - default
### 1: NORMALIZED_QUANTITY_STORAGE_SUPPORTED
### 2: NORMALIZED_QUANTITY_SEARCH_SUPPORTED
# normalized_quantity_search_level: 2
-#elasticsearch:
-# debug:
-# pretty_print_json_log: false
-# refresh_after_write: false
-# enabled: false
-# password: SomePassword
-# required_index_status: YELLOW
-# rest_url: 'localhost:9200'
-# protocol: 'http'
-# schema_management_strategy: CREATE
-# username: SomeUsername
+ oauth2:
+ enabled: "${OAUTH2_ENABLED: false}"
+ issuer: ${OAUTH2_ISSUER}
+ jwks_uri: ${OAUTH2_JWKS_URI}
+elasticsearch:
+ enabled: "${ELASTICSEARCH_ENABLED: false}"
+ debug:
+ pretty_print_json_log: false
+ refresh_after_write: false
+ password: ${ELASTICSEARCH_PASSWORD}
+ required_index_status: YELLOW
+ rest_url: ${ELASTICSEARCH_URL}
+ protocol: ${ELASTICSEARCH_PROTOCOL}
+ schema_management_strategy: CREATE
+ username: ${ELASTICSEARCH_USERNAME}