diff --git a/.github/workflows/chart-lint-checker.yml b/.github/workflows/chart-lint-checker.yml new file mode 100644 index 00000000..5b275422 --- /dev/null +++ b/.github/workflows/chart-lint-checker.yml @@ -0,0 +1,33 @@ +name: Lint and Test Charts + +on: pull_request + +jobs: + lint-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Set up Helm + uses: azure/setup-helm@v1 + with: + version: v3.5.0 + - uses: actions/setup-python@v2 + with: + python-version: 3.7 + - name: Set up chart-testing + uses: helm/chart-testing-action@v2.0.1 + - name: Run chart-testing (list-changed) + id: list-changed + run: | + changed=$(ct list-changed --config ct.yaml) + if [[ -n "$changed" ]]; then + echo "::set-output name=changed::true" + fi + - name: Run chart-testing (lint) + run: ct lint --config ct.yaml + - name: Create kind cluster + uses: helm/kind-action@v1.1.0 + if: steps.list-changed.outputs.changed == 'true' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..ea0e3a88 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,42 @@ +# This workflow will build a Java project with Maven +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven + +name: Java CI with Maven + +on: + push: + branches: + - main + + pull_request: + branches: + - main + +jobs: + run-unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 11 + + - name: Run Unit tests + run: mvn -B clean test --file pom.xml + + run-spotless-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 11 + + - name: Run spotless check + run: mvn spotless:check diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100755 index 00000000..04076438 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,103 @@ +name: Docker + +on: + push: + # Publish `master` as Docker `master` tag. + # See also https://github.com/crazy-max/ghaction-docker-meta#basic + branches: + - main + + # Publish `v1.2.3` tags as releases. + tags: + - v* + + pull_request: + # Run Tests when changes are made to the Docker file + paths: + - 'Dockerfile' + + workflow_dispatch: + +jobs: + # Run image build test + test: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Run Build tests + run: docker build . --file Dockerfile + + push: + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 11 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Cache Docker layers + uses: actions/cache@v2.1.6 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Docker meta + id: docker_meta + uses: docker/metadata-action@v4 + with: + images: opensrp/fhir-gateway + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + #- name: Login to GitHub Container Registry + # uses: docker/login-action@v2 + # with: + # registry: ghcr.io + # username: ${{ github.repository_owner }} + # password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push to Docker Image Repositories + uses: docker/build-push-action@v3 + id: docker_build + with: + push: true + context: . + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.docker_meta.outputs.tags }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + # Temp fix + # https://github.com/docker/build-push-action/issues/252 + # https://github.com/moby/buildkit/issues/1896 + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/publish-chart.yml b/.github/workflows/publish-chart.yml new file mode 100644 index 00000000..9cd8e978 --- /dev/null +++ b/.github/workflows/publish-chart.yml @@ -0,0 +1,64 @@ +# Kindly refer to https://github.com/helm/chart-releaser-action + +name: Publish Charts + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "$GITHUB_ACTOR" + git config user.email "$GITHUB_ACTOR@users.noreply.github.com" + + - name: Install Helm + uses: azure/setup-helm@v1 + with: + version: v3.5.0 + + - name: Run chart-releaser + uses: helm/chart-releaser-action@v1.2.0 # step that writes the latest chart versions (below) depends on this step writing the latest version as the first index in the entries. list in the index.yaml file + env: + CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + - name: Install Python + uses: actions/setup-python@v1 + + - name: Install pip requirements + uses: BSFishy/pip-action@v1 + with: + packages: | + shyaml==0.6.2 + + - name: Checkout gh-pages + uses: actions/checkout@v2 + with: + fetch-depth: 0 + ref: gh-pages + + - name: Record the latest chart versions + run: | + releaseDir="latest" + if [[ ! -d ${releaseDir} ]]; then + mkdir -p ${releaseDir} + fi + charts=($(cat index.yaml | shyaml keys entries)) + for curChart in "${charts[@]}"; do + curChartVersion=$(cat index.yaml | shyaml get-value entries.${curChart}.0.version) + echo ${curChartVersion} > ${releaseDir}/${curChart} + done + + - uses: EndBug/add-and-commit@v7 + with: + message: 'Set the latest the chart versions' + branch: gh-pages diff --git a/.gitignore b/.gitignore index 8a2feaee..c8f3188c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ __pycache__/ # MacOS .DS_Store + +out/ diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index 69c3e3df..b1741dd4 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,8 @@ # # Image for building and running tests against the source code of -# the FHIR Access Proxy. -FROM maven:3.8.5-openjdk-11 as build +# the FHIR Gateway. +FROM maven:3.8.5-openjdk-17-slim as build RUN apt-get update && apt-get install -y nodejs npm RUN npm cache clean -f && npm install -g n && n stable @@ -37,11 +37,12 @@ RUN mvn spotless:check RUN mvn --batch-mode package -Pstandalone-app -Dlicense.skip=true -# Image for FHIR Access Proxy binary with configuration knobs as environment vars. -FROM eclipse-temurin:11-jdk-focal as main +# Image for FHIR Gateway binary with configuration knobs as environment vars. +FROM eclipse-temurin:17-jdk-focal as main -COPY --from=build /app/exec/target/exec-0.2.1-SNAPSHOT.jar / +COPY --from=build /app/exec/target/fhir-gateway-exec.jar / COPY resources/hapi_page_url_allowed_queries.json resources/hapi_page_url_allowed_queries.json +COPY resources/hapi_sync_filter_ignored_queries.json resources/hapi_sync_filter_ignored_queries.json ENV PROXY_PORT=8080 ENV TOKEN_ISSUER="http://localhost/auth/realms/test" @@ -54,4 +55,4 @@ ENV BACKEND_TYPE="HAPI" ENV ACCESS_CHECKER="list" ENV RUN_MODE="PROD" -ENTRYPOINT java -jar exec-0.2.1-SNAPSHOT.jar --server.port=${PROXY_PORT} +ENTRYPOINT java -jar fhir-gateway-exec.jar --server.port=${PROXY_PORT} diff --git a/README.md b/README.md index 90ad6d36..971363cb 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The proxy is also available as a [docker image](Dockerfile): ```shell $ docker run -p 8081:8080 -e TOKEN_ISSUER=[token_issuer_url] \ -e PROXY_TO=[fhir_server_url] -e ACCESS_CHECKER=list \ - us-docker.pkg.dev/fhir-proxy-build/stable/fhir-access-proxy:latest + us-docker.pkg.dev/fhir-proxy-build/stable/fhir-gateway:latest ``` Note if the `TOKEN_ISSUER` is on the `localhost` you may need to bypass proxy's diff --git a/build.sh b/build.sh index 8b3bbc3b..29fa8d37 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -28,4 +28,4 @@ set -e export BUILD_ID=${KOKORO_BUILD_ID:-local} gcloud auth configure-docker us-docker.pkg.dev ./e2e-test/e2e.sh -docker push us-docker.pkg.dev/fhir-proxy-build/stable/fhir-access-proxy:${BUILD_ID} +docker push us-docker.pkg.dev/fhir-proxy-build/stable/fhir-gateway:${BUILD_ID} diff --git a/charts/fhir-gateway/.helmignore b/charts/fhir-gateway/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/charts/fhir-gateway/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/fhir-gateway/Chart.yaml b/charts/fhir-gateway/Chart.yaml new file mode 100644 index 00000000..0408fc6d --- /dev/null +++ b/charts/fhir-gateway/Chart.yaml @@ -0,0 +1,48 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v2 +name: fhir-gateway +description: | + This is a simple access-control proxy that sits in front of a + [FHIR](https://www.hl7.org/fhir/) store (e.g., a + [HAPI FHIR](https://hapifhir.io/) server, + [GCP FHIR store](https://cloud.google.com/healthcare-api/docs/concepts/fhir), + etc.) and controls access to FHIR resources. +icon: https://avatars2.githubusercontent.com/u/7898027?s=200&v=4 +maintainers: + - name: opensrp + email: techops@ona.io +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.0.3 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "0.0.1" diff --git a/charts/fhir-gateway/README.md b/charts/fhir-gateway/README.md new file mode 100644 index 00000000..487aff6a --- /dev/null +++ b/charts/fhir-gateway/README.md @@ -0,0 +1,162 @@ +# FHIR Gateway + +[FHIR Gateway](../../README.md) is a simple access-control proxy that sits in +front of FHIR store and server and controls access to FHIR resources. + +## TL;DR + +```bash +helm repo add opensrp-fhir-gateway https://fhir-gateway.helm.smartregister.org && +helm install fhir-gateway opensrp-fhir-gateway/fhir-gateway +``` + +## Introduction + +This chart bootstraps [FHIR Gateway](../../README.md) deployment on a +[Kubernetes](http://kubernetes.io) cluster using the [Helm](https://helm.sh) +package manager. + +## Prerequisites + +- Kubernetes 1.12+ +- Helm 3.1.0 + +## Installing the Chart + +To install the chart with the release name `fhir-gateway`: + +```shell +helm repo add opensrp-fhir-gateway https://fhir-gateway.helm.smartregister.org && +helm install fhir-gateway opensrp-fhir-gateway/fhir-gateway +``` + +## Uninstalling the Chart + +To uninstall/delete the `fhir-gateway` deployment: + +```shell +helm delete fhir-gateway +``` + +The command removes all the Kubernetes components associated with the chart and +deletes the release. + +## Parameters + +The following table lists the configurable parameters of the FHIR Gateway chart +and their default values. + +## Common Parameters + +| Parameter | Description | Default | +| -------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `replicaCount` | | `1` | +| `image.repository` | | `"opensrp/fhir-gateway"` | +| `image.pullPolicy` | | `"IfNotPresent"` | +| `image.tag` | | `"latest"` | +| `imagePullSecrets` | | `[]` | +| `nameOverride` | | `""` | +| `fullnameOverride` | | `""` | +| `serviceAccount.create` | | `true` | +| `serviceAccount.annotations` | | `{}` | +| `serviceAccount.name` | | `""` | +| `podAnnotations` | | `{}` | +| `podSecurityContext` | | `{}` | +| `securityContext` | | `{}` | +| `service.type` | | `"ClusterIP"` | +| `service.port` | | `80` | +| `ingress.enabled` | | `false` | +| `ingress.className` | | `""` | +| `ingress.annotations` | | `{}` | +| `ingress.hosts` | | `[{"host": "fhir-gateway.local", "paths": [{"path": "/", "pathType": "ImplementationSpecific"}]}]` | +| `ingress.tls` | | `[]` | +| `resources` | | `{}` | +| `autoscaling.enabled` | | `false` | +| `autoscaling.minReplicas` | | `1` | +| `autoscaling.maxReplicas` | | `100` | +| `autoscaling.targetCPUUtilizationPercentage` | | `80` | +| `nodeSelector` | | `{}` | +| `tolerations` | | `[]` | +| `affinity` | | `{}` | +| `recreatePodsWhenConfigMapChange` | | `true` | +| `livenessProbe.httpGet.path` | | `"/.well-known/smart-configuration"` | +| `livenessProbe.httpGet.port` | | `"http"` | +| `readinessProbe.httpGet.path` | | `"/.well-known/smart-configuration"` | +| `readinessProbe.httpGet.port` | | `"http"` | +| `initContainers` | | `null` | +| `volumes` | | `null` | +| `volumeMounts` | | `null` | +| `configMaps` | | `null` | +| `env` | | `[{"name": "PROXY_TO", "value": "https://example.com/fhir"}, {"name": "TOKEN_ISSUER", "value": "http://localhost:9080/auth/realms/test-smart"}, {"name": "ACCESS_CHECKER", "value": "list"}, {"name": "ALLOWED_QUERIES_FILE", "value": "resources/hapi_page_url_allowed_queries.json"}]` | +| `pdb.enabled` | | `false` | +| `pdb.minAvailable` | | `""` | +| `pdb.maxUnavailable` | | `1` | +| `vpa.enabled` | | `false` | +| `vpa.updatePolicy.updateMode` | | `"Off"` | +| `vpa.resourcePolicy` | | `{}` | + +## Overriding Configuration File On Pod Using ConfigMaps + +To update config file on the pod with new changes one has to do the following: + +(Will be showcasing an example of overriding the +[hapi_page_url_allowed_queries.json](../../resources/hapi_page_url_allowed_queries.json) +file). + +1. Create a configmap entry, like below: + + - The `.Values.configMaps.name` should be unique per entry. + - Ensure indentation of the content is okay. + + ```yaml + configMaps: + - name: hapi_page_url_allowed_queries.json + contents: | + { + "entries": [ + { + "path": "", + "queryParams": { + "_getpages": "ANY_VALUE" + }, + "allowExtraParams": true, + "allParamsRequired": true, + "newConfigToAdd": false + } + ] + } + ``` + +2. Create a configmap volume type: + + - The name of the configMap resemble the ConfigMap manifest metadata.name + i.e. `fhir-gateway` but we obtain the generated name from the function + `'{{ include "fhir-gateway.fullname" . }}'` using tpl function. + + ```yaml + volumes: + - name: hapi-page-url-allowed-queries + configMap: + name: '{{ include "fhir-gateway.fullname" . }}' + ``` + +3. Mount the Configmap volume: + + - mountPath is the location of the file in the pod. + - name is the name of the volume in point 2 above. + - subPath is the name of the configMap used in point 1 above. + + ```yaml + volumeMounts: + - mountPath: /app/resources/hapi_page_url_allowed_queries.json + name: hapi-page-url-allowed-queries + subPath: hapi_page_url_allowed_queries.json + ``` + +4. Deploy. + - To confirm it has picked the new changes you can check the file by: + ```shell + kubectl exec -it -- cat resources/hapi_page_url_allowed_queries.json + ``` + +Done. diff --git a/charts/fhir-gateway/templates/NOTES.txt b/charts/fhir-gateway/templates/NOTES.txt new file mode 100644 index 00000000..56934576 --- /dev/null +++ b/charts/fhir-gateway/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "fhir-gateway.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "fhir-gateway.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "fhir-gateway.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "fhir-gateway.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/charts/fhir-gateway/templates/_helpers.tpl b/charts/fhir-gateway/templates/_helpers.tpl new file mode 100644 index 00000000..99e93ac2 --- /dev/null +++ b/charts/fhir-gateway/templates/_helpers.tpl @@ -0,0 +1,74 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "fhir-gateway.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "fhir-gateway.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "fhir-gateway.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "fhir-gateway.labels" -}} +helm.sh/chart: {{ include "fhir-gateway.chart" . }} +{{ include "fhir-gateway.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "fhir-gateway.selectorLabels" -}} +app.kubernetes.io/name: {{ include "fhir-gateway.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "fhir-gateway.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "fhir-gateway.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Populate the pod annotations +*/}} +{{- define "fhir-gateway.podAnnotations" -}} +{{- range $index, $element:=.Values.podAnnotations }} +{{ $index }}: {{ $element | quote }} +{{- end }} +{{- if .Values.recreatePodsWhenConfigMapChange }} +checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} +{{- end }} +{{- end }} diff --git a/charts/fhir-gateway/templates/configmap.yaml b/charts/fhir-gateway/templates/configmap.yaml new file mode 100644 index 00000000..e1de20e2 --- /dev/null +++ b/charts/fhir-gateway/templates/configmap.yaml @@ -0,0 +1,29 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ $scope := .}} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "fhir-gateway.fullname" . }} + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} +data: + {{ range $value := .Values.configMaps -}} + {{ $value.name }}: | + {{ tpl $value.contents $scope | nindent 8 }} + {{ end }} + diff --git a/charts/fhir-gateway/templates/deployment.yaml b/charts/fhir-gateway/templates/deployment.yaml new file mode 100644 index 00000000..07de2113 --- /dev/null +++ b/charts/fhir-gateway/templates/deployment.yaml @@ -0,0 +1,87 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "fhir-gateway.fullname" . }} + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "fhir-gateway.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + {{- include "fhir-gateway.podAnnotations" . | indent 8 }} + labels: + {{- include "fhir-gateway.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "fhir-gateway.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + volumes: + {{- if .Values.volumes }} + {{- tpl (toYaml .Values.volumes) . | nindent 12 }} + {{- end }} + {{- if .Values.initContainers }} + initContainers: + {{- toYaml .Values.initContainers | nindent 12 }} + {{- end }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- if .Values.env }} + env: + {{- tpl (toYaml .Values.env) . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + {{- if .Values.volumeMounts }} + {{- toYaml .Values.volumeMounts | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/fhir-gateway/templates/hpa.yaml b/charts/fhir-gateway/templates/hpa.yaml new file mode 100644 index 00000000..d6f5ac9f --- /dev/null +++ b/charts/fhir-gateway/templates/hpa.yaml @@ -0,0 +1,44 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "fhir-gateway.fullname" . }} + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "fhir-gateway.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/charts/fhir-gateway/templates/ingress.yaml b/charts/fhir-gateway/templates/ingress.yaml new file mode 100644 index 00000000..8e12d62a --- /dev/null +++ b/charts/fhir-gateway/templates/ingress.yaml @@ -0,0 +1,78 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +{{ if .Values.ingress.enabled -}} +{{- $fullName := include "fhir-gateway.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/charts/fhir-gateway/templates/pdb.yaml b/charts/fhir-gateway/templates/pdb.yaml new file mode 100644 index 00000000..58e4f9d8 --- /dev/null +++ b/charts/fhir-gateway/templates/pdb.yaml @@ -0,0 +1,38 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ if .Values.pdb.enabled }} +{{- if semverCompare "<1.21-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: "policy/v1beta1" +{{- else -}} +apiVersion: "policy/v1" +{{- end }} +kind: PodDisruptionBudget +metadata: + name: {{ include "fhir-gateway.fullname" . }} + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} +spec: + {{- if .Values.pdb.minAvailable }} + minAvailable: {{ .Values.pdb.minAvailable }} + {{- end }} + {{- if .Values.pdb.maxUnavailable }} + maxUnavailable: {{ .Values.pdb.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "fhir-gateway.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/fhir-gateway/templates/service.yaml b/charts/fhir-gateway/templates/service.yaml new file mode 100644 index 00000000..7652a830 --- /dev/null +++ b/charts/fhir-gateway/templates/service.yaml @@ -0,0 +1,31 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Service +metadata: + name: {{ include "fhir-gateway.fullname" . }} + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "fhir-gateway.selectorLabels" . | nindent 4 }} diff --git a/charts/fhir-gateway/templates/serviceaccount.yaml b/charts/fhir-gateway/templates/serviceaccount.yaml new file mode 100644 index 00000000..4f0d28fe --- /dev/null +++ b/charts/fhir-gateway/templates/serviceaccount.yaml @@ -0,0 +1,28 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "fhir-gateway.serviceAccountName" . }} + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/fhir-gateway/templates/tests/test-connection.yaml b/charts/fhir-gateway/templates/tests/test-connection.yaml new file mode 100644 index 00000000..a57ff9e7 --- /dev/null +++ b/charts/fhir-gateway/templates/tests/test-connection.yaml @@ -0,0 +1,31 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "fhir-gateway.fullname" . }}-test-connection" + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "fhir-gateway.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/charts/fhir-gateway/templates/vpa.yaml b/charts/fhir-gateway/templates/vpa.yaml new file mode 100644 index 00000000..59337365 --- /dev/null +++ b/charts/fhir-gateway/templates/vpa.yaml @@ -0,0 +1,35 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +{{ if .Values.vpa.enabled }} +apiVersion: "autoscaling.k8s.io/v1" +kind: VerticalPodAutoscaler +metadata: + name: {{ include "fhir-gateway.fullname" . }} + labels: + {{- include "fhir-gateway.labels" . | nindent 4 }} +spec: + targetRef: + apiVersion: "apps/v1" + kind: Deployment + name: {{ include "fhir-gateway.fullname" . }} + updatePolicy: + {{- toYaml .Values.vpa.updatePolicy | nindent 4 }} + {{- if .Values.vpa.resourcePolicy }} + resourcePolicy: + {{- toYaml .Values.vpa.resourcePolicy | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/fhir-gateway/values.yaml b/charts/fhir-gateway/values.yaml new file mode 100644 index 00000000..aa1dd133 --- /dev/null +++ b/charts/fhir-gateway/values.yaml @@ -0,0 +1,160 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Default values for fhir-gateway. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: opensrp/fhir-gateway + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: fhir-gateway.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +recreatePodsWhenConfigMapChange: true + +livenessProbe: + httpGet: + path: /.well-known/smart-configuration + port: http + +readinessProbe: + httpGet: + path: /.well-known/smart-configuration + port: http + +initContainers: + +volumes: +# - name: hapi-page-url-allowed-queries +# configMap: +# name: '{{ include "fhir-gateway.fullname" . }}' + + +volumeMounts: +# - mountPath: /app/resources/hapi_page_url_allowed_queries.json +# name: hapi-page-url-allowed-queries +# subPath: hapi_page_url_allowed_queries.json + +configMaps: +# - name: hapi_page_url_allowed_queries.json +# contents: | +# { +# "entries": [ +# { +# "path": "", +# "queryParams": { +# "_getpages": "ANY_VALUE" +# }, +# "allowExtraParams": true, +# "allParamsRequired": true, +# } +# ] +# } + +env: + - name: PROXY_TO + value: https://example.com/fhir + - name: TOKEN_ISSUER + value: http://localhost:9080/auth/realms/test-smart + - name: ACCESS_CHECKER + value: list + - name: ALLOWED_QUERIES_FILE + value: resources/hapi_page_url_allowed_queries.json + +pdb: + enabled: false + minAvailable: "" + maxUnavailable: 1 + +vpa: + enabled: false + updatePolicy: + updateMode: "Off" + resourcePolicy: {} diff --git a/ct.yaml b/ct.yaml new file mode 100644 index 00000000..14ace63d --- /dev/null +++ b/ct.yaml @@ -0,0 +1,21 @@ +# +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# See https://github.com/helm/chart-testing#configuration +remote: origin +target-branch: main +chart-dirs: + - charts diff --git a/doc/design.md b/doc/design.md index 04cfe6d6..a8e3913b 100644 --- a/doc/design.md +++ b/doc/design.md @@ -320,35 +320,35 @@ varies by context. Each of these approaches are described in the following sections. In each case, we briefly describe what is supported in the first release of the access gateway. The "first release" is when we open-sourced the project in June 2022 in -[this GitHub repository](https://github.com/google/fhir-access-proxy). Let's -first look at the architecture of the gateway. There are two main components: +[this GitHub repository](https://github.com/google/fhir-gateway). Let's first +look at the architecture of the gateway. There are two main components: -**[Server](https://github.com/google/fhir-access-proxy/tree/main/server/src/main/java/com/google/fhir/gateway)**: +**[Server](https://github.com/google/fhir-gateway/tree/main/server/src/main/java/com/google/fhir/gateway)**: The core of the access gateway is the "server" which provides a -[servlet](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java) +[servlet](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java) that processes FHIR queries and an -[authorization interceptor](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java) +[authorization interceptor](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java) that inspects those. The interceptor decodes and validates the JWT access-token and makes a call to an -[AccessChecker](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessChecker.java) +[AccessChecker](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessChecker.java) plugin to decide whether access should be granted or not. The server also provides common FHIR query/resource processing, e.g., -[PatientFinder](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/PatientFinder.java) +[PatientFinder](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/PatientFinder.java) for finding patient context. These libraries are meant to be used in the plugin implementations. -**[AccessChecker plugin](https://github.com/google/fhir-access-proxy/tree/main/plugins)**: +**[AccessChecker plugin](https://github.com/google/fhir-gateway/tree/main/plugins)**: Each access gateway needs at least one AccessChecker plugin. Gateway implementers can provide their customized access-check logic in this plugin. The server code's initialization finds plugins by looking for -[AccessCheckerFactory](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessCheckerFactory.java) +[AccessCheckerFactory](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessCheckerFactory.java) implementations that are [@Named](https://docs.oracle.com/javaee/7/api/javax/inject/Named.html). The specified name is used to select that plugin at runtime. Example implementations are -[ListAccessChecker](https://github.com/google/fhir-access-proxy/blob/main/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java) +[ListAccessChecker](https://github.com/google/fhir-gateway/blob/main/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java) and -[PatientAccessChecker](https://github.com/google/fhir-access-proxy/blob/main/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java). +[PatientAccessChecker](https://github.com/google/fhir-gateway/blob/main/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java). AccessChecker plugins can send RPCs to other backends if they need to collect extra information. In our examples, the plugins consult with the same FHIR store that resources are pulled from, but you could imagine consulting more hardened @@ -367,14 +367,14 @@ The mapping from resources to patients is done through the [patient compartment](https://www.hl7.org/fhir/compartmentdefinition-patient.html) definition. Note that we can still access many resources in one query; in particular through -[Patient/ID/$everything](https://hl7.org/fhir/patient-operation-everything.html) +[Patient/ID/\$everything](https://hl7.org/fhir/patient-operation-everything.html) queries, we can fetch all updates for a single patient. This approach helps support both the **flexible-access-control** and **untrusted-app** items from the [constraints](#scenarios-and-constraints) section. Note to use this approach for access-control, the patient context should be inferred from the FHIR query. The server provides -[a library](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java) +[a library](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java) for doing this. ### Query templates allowed/blocked list @@ -394,7 +394,7 @@ search results of a previous query. Just from these queries, we cannot decide what the patient context is, so we should let those queries go through (there is a security risk here but since `_getpages` param values are ephemeral UUIDs, this is probably ok). Here is a -[sample config](https://github.com/google/fhir-access-proxy/blob/main/resources/hapi_page_url_allowed_queries.json) +[sample config](https://github.com/google/fhir-gateway/blob/main/resources/hapi_page_url_allowed_queries.json) for this. We note that we want our core "server" to be _stateless_ (for easy scalability); therefore cannot store next/prev URLs from previous query results. @@ -424,11 +424,11 @@ structure of FHIR queries that the gateway accepts). So we still need some restrictions on the permitted queries as mentioned above. Among gateway interfaces, there is -[AccessDecision](https://github.com/google/fhir-access-proxy/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java) +[AccessDecision](https://github.com/google/fhir-gateway/blob/main/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java) which is returned from a -[checkAccess](https://github.com/google/fhir-access-proxy/blob/85f7c87a26494d4efba5d01904c8c27074eb26a9/server/src/main/java/com/google/fhir/gateway/interfaces/AccessChecker.java#L31). +[checkAccess](https://github.com/google/fhir-gateway/blob/85f7c87a26494d4efba5d01904c8c27074eb26a9/server/src/main/java/com/google/fhir/gateway/interfaces/AccessChecker.java#L31). This interface has a -[postProcess](https://github.com/google/fhir-access-proxy/blob/85f7c87a26494d4efba5d01904c8c27074eb26a9/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java#L39) +[postProcess](https://github.com/google/fhir-gateway/blob/85f7c87a26494d4efba5d01904c8c27074eb26a9/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java#L39) method which can be used for post-processing of resources returned from the FHIR server. @@ -553,10 +553,10 @@ In the main text, we refer to these examples by "all-patients", ## Notes [^1]: - The simplified - [Implicit](https://smilecdr.com/docs/smart/smart_on_fhir_authorization_flows.html#launch-flow-implicit-grant) - flow could work for our use-case too but that has important security - shortcomings. For example, it exposes access_token in URLs which can leak - through browser history. Another more important shortcoming is that we - cannot implement PKCE in the Implicit flow as the access_token is directly - returned in the first request. + The simplified + [Implicit](https://smilecdr.com/docs/smart/smart_on_fhir_authorization_flows.html#launch-flow-implicit-grant) + flow could work for our use-case too but that has important security + shortcomings. For example, it exposes access_token in URLs which can leak + through browser history. Another more important shortcoming is that we cannot + implement PKCE in the Implicit flow as the access_token is directly returned + in the first request. diff --git a/docker/hapi-proxy-compose.yaml b/docker/hapi-proxy-compose.yaml index bbcbf53e..c0e4b5de 100644 --- a/docker/hapi-proxy-compose.yaml +++ b/docker/hapi-proxy-compose.yaml @@ -1,5 +1,5 @@ # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -35,7 +35,7 @@ version: "3.0" services: fhir-proxy: - image: us-docker.pkg.dev/fhir-proxy-build/stable/fhir-access-proxy:${BUILD_ID:-latest} + image: us-docker.pkg.dev/fhir-proxy-build/stable/fhir-gateway:${BUILD_ID:-latest} environment: - TOKEN_ISSUER - PROXY_TO diff --git a/docker/keycloak/Dockerfile b/docker/keycloak/Dockerfile index bfaf6041..d1e0c198 100644 --- a/docker/keycloak/Dockerfile +++ b/docker/keycloak/Dockerfile @@ -1,3 +1,20 @@ +# +# Copyright 2021-2023 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + # # Copyright 2021-2022 Google LLC # diff --git a/e2e-test/e2e.sh b/e2e-test/e2e.sh index 5f4d1b66..11b2971d 100755 --- a/e2e-test/e2e.sh +++ b/e2e-test/e2e.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Copyright 2021-2022 Google LLC +# Copyright 2021-2023 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -21,7 +21,7 @@ set -e export BUILD_ID=${KOKORO_BUILD_ID:-local} function setup() { - docker build -t us-docker.pkg.dev/fhir-proxy-build/stable/fhir-access-proxy:${BUILD_ID} . + docker build -t us-docker.pkg.dev/fhir-proxy-build/stable/fhir-gateway:${BUILD_ID} . docker-compose -f docker/keycloak/config-compose.yaml \ up --force-recreate --remove-orphans -d --quiet-pull # TODO find a way to expose docker container logs in the output; currently diff --git a/exec/pom.xml b/exec/pom.xml old mode 100644 new mode 100755 index 472f74d0..b8f7caee --- a/exec/pom.xml +++ b/exec/pom.xml @@ -21,7 +21,7 @@ com.google.fhir.gateway fhir-gateway - 0.2.1-SNAPSHOT + 0.1.32 exec @@ -79,6 +79,9 @@ + + ${project.parent.artifactId}-${project.artifactId} + diff --git a/plugins/pom.xml b/plugins/pom.xml old mode 100644 new mode 100755 index bf4334a1..19cfcdd5 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -23,7 +23,7 @@ implementations do not have to do this; they can redeclare those deps. --> com.google.fhir.gateway fhir-gateway - 0.2.1-SNAPSHOT + 0.1.32 plugins @@ -38,6 +38,24 @@ server ${project.version} + + + javax.servlet + javax.servlet-api + 4.0.1 + provided + + + ca.uhn.hapi.fhir + hapi-fhir-client + ${hapifhir_version} + + + org.smartregister + fhir-common-utils + 0.0.10-SNAPSHOT + compile + diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateList.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateList.java index fdd36907..84c9395e 100644 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateList.java +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateList.java @@ -75,7 +75,8 @@ public boolean canAccess() { } @Override - public String postProcess(HttpResponse response) throws IOException { + public String postProcess(RequestDetailsReader request, HttpResponse response) + throws IOException { Preconditions.checkState(HttpUtil.isResponseValid(response)); String content = CharStreams.toString(HttpUtil.readerFromEntity(response.getEntity())); IParser parser = fhirContext.newJsonParser(); @@ -97,7 +98,7 @@ public String postProcess(HttpResponse response) throws IOException { if (FhirUtil.isSameResourceType(resource.fhirType(), ResourceType.Bundle)) { // TODO Response potentially too large to be loaded into memory; see: - // https://github.com/google/fhir-access-proxy/issues/64 + // https://github.com/google/fhir-gateway/issues/64 Bundle bundle = (Bundle) parser.parseResource(content); Set patientIdsInResponse = Sets.newHashSet(); @@ -120,7 +121,7 @@ public String postProcess(HttpResponse response) throws IOException { private void addPatientToList(String newPatient) throws IOException { Preconditions.checkNotNull(newPatient); // TODO create this with HAPI client instead of handcrafting; see: - // https://github.com/google/fhir-access-proxy/issues/65 + // https://github.com/google/fhir-gateway/issues/65 String jsonPatch = String.format( "[{" @@ -136,7 +137,7 @@ private void addPatientToList(String newPatient) throws IOException { newPatient); logger.info("Updating access list {} with patch {}", patientListId, jsonPatch); // TODO decide how to handle failures in access list updates; see: - // https://github.com/google/fhir-access-proxy/issues/66 + // https://github.com/google/fhir-gateway/issues/66 httpFhirClient.patchResource( String.format("List/%s", PARAM_ESCAPER.escape(patientListId)), jsonPatch); } diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java index d42f3337..eda44287 100644 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/ListAccessChecker.java @@ -113,7 +113,7 @@ private boolean serverListIncludesAnyPatient(Set patientIds) { return false; } // TODO consider using the HAPI FHIR client instead; see: - // https://github.com/google/fhir-access-proxy/issues/65. + // https://github.com/google/fhir-gateway/issues/65. String patientParam = queryBuilder(patientIds, PARAM_ESCAPER.escape("Patient/"), PARAM_ESCAPER.escape(",")); return listIncludesItems("item=" + patientParam); @@ -132,7 +132,7 @@ private boolean serverListIncludesAllPatients(Set patientIds) { private boolean patientsExist(String patientId) throws IOException { // TODO consider using the HAPI FHIR client instead; see: - // https://github.com/google/fhir-access-proxy/issues/65 + // https://github.com/google/fhir-gateway/issues/65 String searchQuery = String.format("/Patient?_id=%s&_elements=id", PARAM_ESCAPER.escape(patientId)); HttpResponse response = httpFhirClient.getResource(searchQuery); @@ -235,7 +235,7 @@ private AccessDecision processDelete(RequestDetailsReader requestDetails) { return NoOpAccessDecision.accessDenied(); } - // TODO(https://github.com/google/fhir-access-proxy/issues/63):Support direct resource deletion. + // TODO(https://github.com/google/fhir-gateway/issues/63):Support direct resource deletion. // There should be a patient id in search params; the param name is based on the resource. String patientId = patientFinder.findPatientFromParams(requestDetails); diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java index 9db77003..d2fc96f8 100644 --- a/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/PatientAccessChecker.java @@ -158,7 +158,7 @@ private AccessDecision processDelete(RequestDetailsReader requestDetails) { if (FhirUtil.isSameResourceType(requestDetails.getResourceName(), ResourceType.Patient)) { return NoOpAccessDecision.accessDenied(); } - // TODO(https://github.com/google/fhir-access-proxy/issues/63):Support direct resource deletion. + // TODO(https://github.com/google/fhir-gateway/issues/63):Support direct resource deletion. String patientId = patientFinder.findPatientFromParams(requestDetails); return new NoOpAccessDecision( authorizedPatientId.equals(patientId) diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/PermissionAccessChecker.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/PermissionAccessChecker.java new file mode 100755 index 00000000..8d729b43 --- /dev/null +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/PermissionAccessChecker.java @@ -0,0 +1,365 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway.plugin; + +import static com.google.fhir.gateway.ProxyConstants.SYNC_STRATEGY; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.model.api.IQueryParameterType; +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.param.TokenParam; +import ca.uhn.fhir.rest.server.exceptions.AuthenticationException; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.fhir.gateway.*; +import com.google.fhir.gateway.interfaces.*; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.util.*; +import java.util.stream.Collectors; +import javax.inject.Named; +import org.apache.commons.lang3.StringUtils; +import org.hl7.fhir.r4.model.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.smartregister.model.practitioner.PractitionerDetails; +import org.smartregister.utils.Constants; + +public class PermissionAccessChecker implements AccessChecker { + private static final Logger logger = LoggerFactory.getLogger(PermissionAccessChecker.class); + private final ResourceFinder resourceFinder; + private final List userRoles; + private SyncAccessDecision syncAccessDecision; + + private PermissionAccessChecker( + String keycloakUUID, + List userRoles, + ResourceFinderImp resourceFinder, + String applicationId, + List careTeamIds, + List locationIds, + List organizationIds, + String syncStrategy) { + Preconditions.checkNotNull(userRoles); + Preconditions.checkNotNull(resourceFinder); + Preconditions.checkNotNull(applicationId); + Preconditions.checkNotNull(careTeamIds); + Preconditions.checkNotNull(organizationIds); + Preconditions.checkNotNull(locationIds); + Preconditions.checkNotNull(syncStrategy); + this.resourceFinder = resourceFinder; + this.userRoles = userRoles; + this.syncAccessDecision = + new SyncAccessDecision( + keycloakUUID, + applicationId, + true, + locationIds, + careTeamIds, + organizationIds, + syncStrategy, + userRoles); + } + + @Override + public AccessDecision checkAccess(RequestDetailsReader requestDetails) { + // For a Bundle requestDetails.getResourceName() returns null + if (requestDetails.getRequestType() == RequestTypeEnum.POST + && requestDetails.getResourceName() == null) { + return processBundle(requestDetails); + + } else { + + boolean userHasRole = + checkUserHasRole( + requestDetails.getResourceName(), requestDetails.getRequestType().name()); + + RequestTypeEnum requestType = requestDetails.getRequestType(); + + switch (requestType) { + case GET: + return processGet(userHasRole); + case DELETE: + return processDelete(userHasRole); + case POST: + return processPost(userHasRole); + case PUT: + return processPut(userHasRole); + default: + // TODO handle other cases like PATCH + return NoOpAccessDecision.accessDenied(); + } + } + } + + private boolean checkUserHasRole(String resourceName, String requestType) { + return checkIfRoleExists(getAdminRoleName(resourceName), this.userRoles) + || checkIfRoleExists(getRelevantRoleName(resourceName, requestType), this.userRoles); + } + + private AccessDecision processGet(boolean userHasRole) { + return getAccessDecision(userHasRole); + } + + private AccessDecision processDelete(boolean userHasRole) { + return getAccessDecision(userHasRole); + } + + private AccessDecision getAccessDecision(boolean userHasRole) { + return userHasRole ? syncAccessDecision : NoOpAccessDecision.accessDenied(); + } + + private AccessDecision processPost(boolean userHasRole) { + return getAccessDecision(userHasRole); + } + + private AccessDecision processPut(boolean userHasRole) { + return getAccessDecision(userHasRole); + } + + private AccessDecision processBundle(RequestDetailsReader requestDetails) { + boolean hasMissingRole = false; + List resourcesInBundle = resourceFinder.findResourcesInBundle(requestDetails); + // Verify Authorization for individual requests in Bundle + for (BundleResources bundleResources : resourcesInBundle) { + if (!checkUserHasRole( + bundleResources.getResource().fhirType(), bundleResources.getRequestType().name())) { + + if (isDevMode()) { + hasMissingRole = true; + logger.info( + "Missing role " + + getRelevantRoleName( + bundleResources.getResource().fhirType(), + bundleResources.getRequestType().name())); + } else { + return NoOpAccessDecision.accessDenied(); + } + } + } + + return (isDevMode() && !hasMissingRole) || !isDevMode() + ? NoOpAccessDecision.accessGranted() + : NoOpAccessDecision.accessDenied(); + } + + private String getRelevantRoleName(String resourceName, String methodType) { + return methodType + "_" + resourceName.toUpperCase(); + } + + private String getAdminRoleName(String resourceName) { + return "MANAGE_" + resourceName.toUpperCase(); + } + + @VisibleForTesting + protected boolean isDevMode() { + return FhirProxyServer.isDevMode(); + } + + private boolean checkIfRoleExists(String roleName, List existingRoles) { + return existingRoles.contains(roleName); + } + + @Named(value = "permission") + static class Factory implements AccessCheckerFactory { + + @VisibleForTesting static final String REALM_ACCESS_CLAIM = "realm_access"; + @VisibleForTesting static final String ROLES = "roles"; + + @VisibleForTesting static final String FHIR_CORE_APPLICATION_ID_CLAIM = "fhir_core_app_id"; + + @VisibleForTesting static final String PROXY_TO_ENV = "PROXY_TO"; + + private List getUserRolesFromJWT(DecodedJWT jwt) { + Claim claim = jwt.getClaim(REALM_ACCESS_CLAIM); + Map roles = claim.asMap(); + List rolesList = (List) roles.get(ROLES); + return rolesList; + } + + private String getApplicationIdFromJWT(DecodedJWT jwt) { + return JwtUtil.getClaimOrDie(jwt, FHIR_CORE_APPLICATION_ID_CLAIM); + } + + private IGenericClient createFhirClientForR4() { + String fhirServer = System.getenv(PROXY_TO_ENV); + FhirContext ctx = FhirContext.forR4(); + IGenericClient client = ctx.newRestfulGenericClient(fhirServer); + return client; + } + + private Composition readCompositionResource(String applicationId) { + IGenericClient client = createFhirClientForR4(); + Bundle compositionBundle = + client + .search() + .forResource(Composition.class) + .where(Composition.IDENTIFIER.exactly().identifier(applicationId)) + .returnBundle(Bundle.class) + .execute(); + List compositionEntries = + compositionBundle != null + ? compositionBundle.getEntry() + : Collections.singletonList(new Bundle.BundleEntryComponent()); + Bundle.BundleEntryComponent compositionEntry = + compositionEntries.size() > 0 ? compositionEntries.get(0) : null; + return compositionEntry != null ? (Composition) compositionEntry.getResource() : null; + } + + private String getBinaryResourceReference(Composition composition) { + List indexes = new ArrayList<>(); + String id = ""; + if (composition != null && composition.getSection() != null) { + indexes = + composition.getSection().stream() + .filter(v -> v.getFocus().getIdentifier() != null) + .filter(v -> v.getFocus().getIdentifier().getValue() != null) + .filter(v -> v.getFocus().getIdentifier().getValue().equals("application")) + .map(v -> composition.getSection().indexOf(v)) + .collect(Collectors.toList()); + Composition.SectionComponent sectionComponent = composition.getSection().get(0); + Reference focus = sectionComponent != null ? sectionComponent.getFocus() : null; + id = focus != null ? focus.getReference() : null; + } + return id; + } + + private Binary findApplicationConfigBinaryResource(String binaryResourceId) { + IGenericClient client = createFhirClientForR4(); + Binary binary = null; + if (!binaryResourceId.isBlank()) { + binary = client.read().resource(Binary.class).withId(binaryResourceId).execute(); + } + return binary; + } + + private String findSyncStrategy(Binary binary) { + byte[] bytes = + binary != null && binary.getDataElement() != null + ? Base64.getDecoder().decode(binary.getDataElement().getValueAsString()) + : null; + String syncStrategy = Constants.EMPTY_STRING; + if (bytes != null) { + String json = new String(bytes); + JsonObject jsonObject = new Gson().fromJson(json, JsonObject.class); + JsonArray jsonArray = jsonObject.getAsJsonArray(SYNC_STRATEGY); + if (jsonArray != null && !jsonArray.isEmpty()) + syncStrategy = jsonArray.get(0).getAsString(); + } + return syncStrategy; + } + + private PractitionerDetails readPractitionerDetails(String keycloakUUID) { + IGenericClient client = createFhirClientForR4(); + // Map<> + Bundle practitionerDetailsBundle = + client + .search() + .forResource(PractitionerDetails.class) + .where(getMapForWhere(keycloakUUID)) + .returnBundle(Bundle.class) + .execute(); + + List practitionerDetailsBundleEntry = + practitionerDetailsBundle.getEntry(); + Bundle.BundleEntryComponent practitionerDetailEntry = + practitionerDetailsBundleEntry != null && practitionerDetailsBundleEntry.size() > 0 + ? practitionerDetailsBundleEntry.get(0) + : null; + return practitionerDetailEntry != null + ? (PractitionerDetails) practitionerDetailEntry.getResource() + : null; + } + + public Map> getMapForWhere(String keycloakUUID) { + Map> hmOut = new HashMap<>(); + // Adding keycloak-uuid + TokenParam tokenParam = new TokenParam("keycloak-uuid"); + tokenParam.setValue(keycloakUUID); + List lst = new ArrayList(); + lst.add(tokenParam); + hmOut.put(PractitionerDetails.SP_KEYCLOAK_UUID, lst); + + return hmOut; + } + + @Override + public AccessChecker create( + DecodedJWT jwt, + HttpFhirClient httpFhirClient, + FhirContext fhirContext, + PatientFinder patientFinder) + throws AuthenticationException { + List userRoles = getUserRolesFromJWT(jwt); + String applicationId = getApplicationIdFromJWT(jwt); + Composition composition = readCompositionResource(applicationId); + String binaryResourceReference = getBinaryResourceReference(composition); + Binary binary = findApplicationConfigBinaryResource(binaryResourceReference); + String syncStrategy = findSyncStrategy(binary); + PractitionerDetails practitionerDetails = readPractitionerDetails(jwt.getSubject()); + List careTeams; + List organizations; + List careTeamIds = new ArrayList<>(); + List organizationIds = new ArrayList<>(); + List locationIds = new ArrayList<>(); + if (StringUtils.isNotBlank(syncStrategy)) { + if (syncStrategy.equals(Constants.CARE_TEAM)) { + careTeams = + practitionerDetails != null + && practitionerDetails.getFhirPractitionerDetails() != null + ? practitionerDetails.getFhirPractitionerDetails().getCareTeams() + : Collections.singletonList(new CareTeam()); + for (CareTeam careTeam : careTeams) { + if (careTeam.getIdElement() != null) { + careTeamIds.add(careTeam.getIdElement().getIdPart()); + } + } + } else if (syncStrategy.equals(Constants.ORGANIZATION)) { + organizations = + practitionerDetails != null + && practitionerDetails.getFhirPractitionerDetails() != null + ? practitionerDetails.getFhirPractitionerDetails().getOrganizations() + : Collections.singletonList(new Organization()); + for (Organization organization : organizations) { + if (organization.getIdElement() != null) { + organizationIds.add(organization.getIdElement().getIdPart()); + } + } + } else if (syncStrategy.equals(Constants.LOCATION)) { + locationIds = + practitionerDetails != null + && practitionerDetails.getFhirPractitionerDetails() != null + ? PractitionerDetailsEndpointHelper.getAttributedLocations( + practitionerDetails.getFhirPractitionerDetails().getLocationHierarchyList()) + : locationIds; + } + } + return new PermissionAccessChecker( + jwt.getSubject(), + userRoles, + ResourceFinderImp.getInstance(fhirContext), + applicationId, + careTeamIds, + locationIds, + organizationIds, + syncStrategy); + } + } +} diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/PractitionerDetailsEndpointHelper.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/PractitionerDetailsEndpointHelper.java new file mode 100644 index 00000000..3806cce8 --- /dev/null +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/PractitionerDetailsEndpointHelper.java @@ -0,0 +1,548 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway.plugin; + +import static org.smartregister.utils.Constants.EMPTY_STRING; + +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.gclient.ReferenceClientParam; +import com.google.fhir.gateway.ProxyConstants; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.apache.commons.lang3.StringUtils; +import org.hl7.fhir.r4.model.BaseResource; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.CareTeam; +import org.hl7.fhir.r4.model.Enumerations; +import org.hl7.fhir.r4.model.Group; +import org.hl7.fhir.r4.model.Identifier; +import org.hl7.fhir.r4.model.Location; +import org.hl7.fhir.r4.model.Organization; +import org.hl7.fhir.r4.model.OrganizationAffiliation; +import org.hl7.fhir.r4.model.Practitioner; +import org.hl7.fhir.r4.model.PractitionerRole; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.smartregister.model.location.LocationHierarchy; +import org.smartregister.model.location.ParentChildrenMap; +import org.smartregister.model.practitioner.FhirPractitionerDetails; +import org.smartregister.model.practitioner.PractitionerDetails; +import org.smartregister.utils.Constants; +import org.springframework.lang.Nullable; + +public class PractitionerDetailsEndpointHelper { + private static final Logger logger = + LoggerFactory.getLogger(PractitionerDetailsEndpointHelper.class); + public static final String PRACTITIONER_GROUP_CODE = "405623001"; + public static final String HTTP_SNOMED_INFO_SCT = "http://snomed.info/sct"; + public static final Bundle EMPTY_BUNDLE = new Bundle(); + private IGenericClient r4FhirClient; + + public PractitionerDetailsEndpointHelper(IGenericClient fhirClient) { + this.r4FhirClient = fhirClient; + } + + private IGenericClient getFhirClientForR4() { + return r4FhirClient; + } + + public PractitionerDetails getPractitionerDetailsByKeycloakId(String keycloakUuid) { + PractitionerDetails practitionerDetails = new PractitionerDetails(); + + logger.info("Searching for practitioner with identifier: " + keycloakUuid); + Practitioner practitioner = getPractitionerByIdentifier(keycloakUuid); + + if (practitioner != null) { + + practitionerDetails = getPractitionerDetailsByPractitioner(practitioner); + + } else { + logger.error("Practitioner with KC identifier: " + keycloakUuid + " not found"); + practitionerDetails.setId(Constants.PRACTITIONER_NOT_FOUND); + } + + return practitionerDetails; + } + + public Bundle getSupervisorPractitionerDetailsByKeycloakId(String keycloakUuid) { + Bundle bundle = new Bundle(); + + logger.info("Searching for practitioner with identifier: " + keycloakUuid); + Practitioner practitioner = getPractitionerByIdentifier(keycloakUuid); + + if (practitioner != null) { + + bundle = getAttributedPractitionerDetailsByPractitioner(practitioner); + + } else { + logger.error("Practitioner with KC identifier: " + keycloakUuid + " not found"); + } + + return bundle; + } + + private Bundle getAttributedPractitionerDetailsByPractitioner(Practitioner practitioner) { + Bundle responseBundle = new Bundle(); + List attributedPractitioners = new ArrayList<>(); + PractitionerDetails practitionerDetails = getPractitionerDetailsByPractitioner(practitioner); + + List careTeamList = practitionerDetails.getFhirPractitionerDetails().getCareTeams(); + // Get other guys. + + List careTeamManagingOrganizationIds = + getManagingOrganizationsOfCareTeamIds(careTeamList); + List supervisorCareTeamOrganizationLocationIds = + getOrganizationAffiliationsByOrganizationIds(careTeamManagingOrganizationIds); + List officialLocationIds = + getOfficialLocationIdentifiersByLocationIds(supervisorCareTeamOrganizationLocationIds); + List locationHierarchies = + getLocationsHierarchyByOfficialLocationIdentifiers(officialLocationIds); + List attributedLocationsList = getAttributedLocations(locationHierarchies); + List attributedOrganizationIds = + getOrganizationIdsByLocationIds(attributedLocationsList); + + // Get care teams by organization Ids + List attributedCareTeams = getCareTeamsByOrganizationIds(attributedOrganizationIds); + + for (CareTeam careTeam : careTeamList) { + attributedCareTeams.removeIf(it -> it.getId().equals(careTeam.getId())); + } + + careTeamList.addAll(attributedCareTeams); + + for (CareTeam careTeam : careTeamList) { + // Add current supervisor practitioners + attributedPractitioners.addAll( + careTeam.getParticipant().stream() + .filter( + it -> + it.hasMember() + && it.getMember() + .getReference() + .startsWith(Enumerations.ResourceType.PRACTITIONER.toCode())) + .map( + it -> + getPractitionerByIdentifier( + getReferenceIDPart(it.getMember().getReference()))) + .collect(Collectors.toList())); + } + + List bundleEntryComponentList = new ArrayList<>(); + + for (Practitioner attributedPractitioner : attributedPractitioners) { + bundleEntryComponentList.add( + new Bundle.BundleEntryComponent() + .setResource(getPractitionerDetailsByPractitioner(attributedPractitioner))); + } + + responseBundle.setEntry(bundleEntryComponentList); + responseBundle.setTotal(bundleEntryComponentList.size()); + return responseBundle; + } + + @NotNull + public static List getAttributedLocations(List locationHierarchies) { + List parentChildrenList = + locationHierarchies.stream() + .flatMap( + locationHierarchy -> + locationHierarchy + .getLocationHierarchyTree() + .getLocationsHierarchy() + .getParentChildren() + .stream()) + .collect(Collectors.toList()); + List attributedLocationsList = + parentChildrenList.stream() + .flatMap(parentChildren -> parentChildren.getChildIdentifiers().stream()) + .map(it -> getReferenceIDPart(it.toString())) + .collect(Collectors.toList()); + return attributedLocationsList; + } + + private List getOrganizationIdsByLocationIds(List attributedLocationsList) { + if (attributedLocationsList == null || attributedLocationsList.isEmpty()) { + return new ArrayList<>(); + } + + Bundle organizationAffiliationsBundle = + getFhirClientForR4() + .search() + .forResource(OrganizationAffiliation.class) + .where(OrganizationAffiliation.LOCATION.hasAnyOfIds(attributedLocationsList)) + .returnBundle(Bundle.class) + .execute(); + + return organizationAffiliationsBundle.getEntry().stream() + .map( + bundleEntryComponent -> + getReferenceIDPart( + ((OrganizationAffiliation) bundleEntryComponent.getResource()) + .getOrganization() + .getReference())) + .distinct() + .collect(Collectors.toList()); + } + + private String getPractitionerIdentifier(Practitioner practitioner) { + String practitionerId = EMPTY_STRING; + if (practitioner.getIdElement() != null && practitioner.getIdElement().getIdPart() != null) { + practitionerId = practitioner.getIdElement().getIdPart(); + } + return practitionerId; + } + + private PractitionerDetails getPractitionerDetailsByPractitioner(Practitioner practitioner) { + + PractitionerDetails practitionerDetails = new PractitionerDetails(); + FhirPractitionerDetails fhirPractitionerDetails = new FhirPractitionerDetails(); + String practitionerId = getPractitionerIdentifier(practitioner); + + logger.info("Searching for care teams for practitioner with id: " + practitioner); + Bundle careTeams = getCareTeams(practitionerId); + List careTeamsList = mapBundleToCareTeams(careTeams); + fhirPractitionerDetails.setCareTeams(careTeamsList); + fhirPractitionerDetails.setPractitioners(Arrays.asList(practitioner)); + + logger.info("Searching for Organizations tied with CareTeams: "); + List careTeamManagingOrganizationIds = + getManagingOrganizationsOfCareTeamIds(careTeamsList); + + Bundle careTeamManagingOrganizations = getOrganizationsById(careTeamManagingOrganizationIds); + logger.info("Managing Organization are fetched"); + + List managingOrganizationTeams = + mapBundleToOrganizations(careTeamManagingOrganizations); + + logger.info("Searching for organizations of practitioner with id: " + practitioner); + + List practitionerRoleList = + getPractitionerRolesByPractitionerId(practitionerId); + logger.info("Practitioner Roles are fetched"); + + List practitionerOrganizationIds = + getOrganizationIdsByPractitionerRoles(practitionerRoleList); + + Bundle practitionerOrganizations = getOrganizationsById(practitionerOrganizationIds); + + List teams = mapBundleToOrganizations(practitionerOrganizations); + // TODO Fix Distinct + List bothOrganizations = + Stream.concat(managingOrganizationTeams.stream(), teams.stream()) + .distinct() + .collect(Collectors.toList()); + + fhirPractitionerDetails.setOrganizations(bothOrganizations); + fhirPractitionerDetails.setPractitionerRoles(practitionerRoleList); + + Bundle groupsBundle = getGroupsAssignedToPractitioner(practitionerId); + logger.info("Groups are fetched"); + + List groupsList = mapBundleToGroups(groupsBundle); + fhirPractitionerDetails.setGroups(groupsList); + fhirPractitionerDetails.setId(practitionerId); + + logger.info("Searching for locations by organizations"); + + Bundle organizationAffiliationsBundle = + getOrganizationAffiliationsByOrganizationIdsBundle( + Stream.concat( + careTeamManagingOrganizationIds.stream(), practitionerOrganizationIds.stream()) + .distinct() + .collect(Collectors.toList())); + + List organizationAffiliations = + mapBundleToOrganizationAffiliation(organizationAffiliationsBundle); + + fhirPractitionerDetails.setOrganizationAffiliations(organizationAffiliations); + + List locationIds = + getLocationIdentifiersByOrganizationAffiliations(organizationAffiliations); + + List locationsIdentifiers = + getOfficialLocationIdentifiersByLocationIds( + locationIds); // TODO Investigate why the Location ID and official identifiers are + // different + + logger.info("Searching for location hierarchy list by locations identifiers"); + List locationHierarchyList = + getLocationsHierarchyByOfficialLocationIdentifiers(locationsIdentifiers); + fhirPractitionerDetails.setLocationHierarchyList(locationHierarchyList); + + logger.info("Searching for locations by ids"); + List locationsList = getLocationsByIds(locationIds); + fhirPractitionerDetails.setLocations(locationsList); + + practitionerDetails.setId(practitionerId); + practitionerDetails.setFhirPractitionerDetails(fhirPractitionerDetails); + + return practitionerDetails; + } + + private List mapBundleToOrganizations(Bundle organizationBundle) { + return organizationBundle.getEntry().stream() + .map(bundleEntryComponent -> (Organization) bundleEntryComponent.getResource()) + .collect(Collectors.toList()); + } + + private Bundle getGroupsAssignedToPractitioner(String practitionerId) { + return getFhirClientForR4() + .search() + .forResource(Group.class) + .where(Group.MEMBER.hasId(practitionerId)) + .where(Group.CODE.exactly().systemAndCode(HTTP_SNOMED_INFO_SCT, PRACTITIONER_GROUP_CODE)) + .returnBundle(Bundle.class) + .execute(); + } + + public static Predicate distinctByKey(Function keyExtractor) { + Set seen = ConcurrentHashMap.newKeySet(); + return t -> seen.add(keyExtractor.apply(t)); + } + + private List getPractitionerRolesByPractitionerId(String practitionerId) { + Bundle practitionerRoles = getPractitionerRoles(practitionerId); + return mapBundleToPractitionerRolesWithOrganization(practitionerRoles); + } + + private List getOrganizationIdsByPractitionerRoles( + List practitionerRoles) { + return practitionerRoles.stream() + .filter(practitionerRole -> practitionerRole.hasOrganization()) + .map(it -> getReferenceIDPart(it.getOrganization().getReference())) + .collect(Collectors.toList()); + } + + private Practitioner getPractitionerByIdentifier(String identifier) { + Bundle resultBundle = + getFhirClientForR4() + .search() + .forResource(Practitioner.class) + .where(Practitioner.IDENTIFIER.exactly().identifier(identifier)) + .returnBundle(Bundle.class) + .execute(); + + return resultBundle != null + ? (Practitioner) resultBundle.getEntryFirstRep().getResource() + : null; + } + + private List getCareTeamsByOrganizationIds(List organizationIds) { + if (organizationIds.isEmpty()) return new ArrayList<>(); + + Bundle bundle = + getFhirClientForR4() + .search() + .forResource(CareTeam.class) + .where( + CareTeam.PARTICIPANT.hasAnyOfIds( + organizationIds.stream() + .map( + it -> + Enumerations.ResourceType.ORGANIZATION.toCode() + + Constants.FORWARD_SLASH + + it) + .collect(Collectors.toList()))) + .returnBundle(Bundle.class) + .execute(); + + return bundle.getEntry().stream() + .filter(it -> ((CareTeam) it.getResource()).hasManagingOrganization()) + .map(it -> ((CareTeam) it.getResource())) + .collect(Collectors.toList()); + } + + private Bundle getCareTeams(String practitionerId) { + logger.info("Searching for Care Teams with practitioner id :" + practitionerId); + + return getFhirClientForR4() + .search() + .forResource(CareTeam.class) + .where( + CareTeam.PARTICIPANT.hasId( + Enumerations.ResourceType.PRACTITIONER.toCode() + + Constants.FORWARD_SLASH + + practitionerId)) + .returnBundle(Bundle.class) + .execute(); + } + + private Bundle getPractitionerRoles(String practitionerId) { + logger.info("Searching for Practitioner roles with practitioner id :" + practitionerId); + return getFhirClientForR4() + .search() + .forResource(PractitionerRole.class) + .where(PractitionerRole.PRACTITIONER.hasId(practitionerId)) + .returnBundle(Bundle.class) + .execute(); + } + + private static String getReferenceIDPart(String reference) { + return reference.substring(reference.indexOf(Constants.FORWARD_SLASH) + 1); + } + + private Bundle getOrganizationsById(List organizationIds) { + return organizationIds.isEmpty() + ? EMPTY_BUNDLE + : getFhirClientForR4() + .search() + .forResource(Organization.class) + .where(new ReferenceClientParam(BaseResource.SP_RES_ID).hasAnyOfIds(organizationIds)) + .returnBundle(Bundle.class) + .execute(); + } + + private @Nullable List getLocationsByIds(List locationIds) { + if (locationIds == null || locationIds.isEmpty()) { + return new ArrayList<>(); + } + + Bundle locationsBundle = + getFhirClientForR4() + .search() + .forResource(Location.class) + .where(new ReferenceClientParam(BaseResource.SP_RES_ID).hasAnyOfIds(locationIds)) + .returnBundle(Bundle.class) + .execute(); + + return locationsBundle.getEntry().stream() + .map(bundleEntryComponent -> ((Location) bundleEntryComponent.getResource())) + .collect(Collectors.toList()); + } + + private @Nullable List getOfficialLocationIdentifiersByLocationIds( + List locationIds) { + if (locationIds == null || locationIds.isEmpty()) { + return new ArrayList<>(); + } + + List locations = getLocationsByIds(locationIds); + + return locations.stream() + .map( + it -> + it.getIdentifier().stream() + .filter( + id -> id.hasUse() && id.getUse().equals(Identifier.IdentifierUse.OFFICIAL)) + .map(it2 -> it2.getValue()) + .collect(Collectors.toList())) + .flatMap(it3 -> it3.stream()) + .collect(Collectors.toList()); + } + + private List getOrganizationAffiliationsByOrganizationIds(List organizationIds) { + if (organizationIds == null || organizationIds.isEmpty()) { + return new ArrayList<>(); + } + Bundle organizationAffiliationsBundle = + getOrganizationAffiliationsByOrganizationIdsBundle(organizationIds); + List organizationAffiliations = + mapBundleToOrganizationAffiliation(organizationAffiliationsBundle); + return getLocationIdentifiersByOrganizationAffiliations(organizationAffiliations); + } + + private Bundle getOrganizationAffiliationsByOrganizationIdsBundle(List organizationIds) { + return organizationIds.isEmpty() + ? EMPTY_BUNDLE + : getFhirClientForR4() + .search() + .forResource(OrganizationAffiliation.class) + .where(OrganizationAffiliation.PRIMARY_ORGANIZATION.hasAnyOfIds(organizationIds)) + .returnBundle(Bundle.class) + .execute(); + } + + private List getLocationIdentifiersByOrganizationAffiliations( + List organizationAffiliations) { + + return organizationAffiliations.stream() + .map( + organizationAffiliation -> + getReferenceIDPart( + organizationAffiliation.getLocation().stream() + .findFirst() + .get() + .getReference())) + .collect(Collectors.toList()); + } + + private List getManagingOrganizationsOfCareTeamIds(List careTeamsList) { + logger.info("Searching for Organizations with care teams list of size:" + careTeamsList.size()); + return careTeamsList.stream() + .filter(careTeam -> careTeam.hasManagingOrganization()) + .flatMap(it -> it.getManagingOrganization().stream()) + .map(it -> getReferenceIDPart(it.getReference())) + .collect(Collectors.toList()); + } + + private List mapBundleToCareTeams(Bundle careTeams) { + return careTeams.getEntry().stream() + .map(bundleEntryComponent -> (CareTeam) bundleEntryComponent.getResource()) + .collect(Collectors.toList()); + } + + private List mapBundleToPractitionerRolesWithOrganization( + Bundle practitionerRoles) { + return practitionerRoles.getEntry().stream() + .map(it -> (PractitionerRole) it.getResource()) + .collect(Collectors.toList()); + } + + private List mapBundleToGroups(Bundle groupsBundle) { + return groupsBundle.getEntry().stream() + .map(bundleEntryComponent -> (Group) bundleEntryComponent.getResource()) + .collect(Collectors.toList()); + } + + private List mapBundleToOrganizationAffiliation( + Bundle organizationAffiliationBundle) { + return organizationAffiliationBundle.getEntry().stream() + .map(bundleEntryComponent -> (OrganizationAffiliation) bundleEntryComponent.getResource()) + .collect(Collectors.toList()); + } + + private List getLocationsHierarchyByOfficialLocationIdentifiers( + List officialLocationIdentifiers) { + if (officialLocationIdentifiers.isEmpty()) return new ArrayList<>(); + + Bundle bundle = + getFhirClientForR4() + .search() + .forResource(LocationHierarchy.class) + .where(LocationHierarchy.IDENTIFIER.exactly().codes(officialLocationIdentifiers)) + .returnBundle(Bundle.class) + .execute(); + + return bundle.getEntry().stream() + .map(it -> ((LocationHierarchy) it.getResource())) + .collect(Collectors.toList()); + } + + public static String createSearchTagValues(Map.Entry entry) { + return entry.getKey() + + ProxyConstants.CODE_URL_VALUE_SEPARATOR + + StringUtils.join( + entry.getValue(), + ProxyConstants.PARAM_VALUES_SEPARATOR + + entry.getKey() + + ProxyConstants.CODE_URL_VALUE_SEPARATOR); + } +} diff --git a/plugins/src/main/java/com/google/fhir/gateway/plugin/SyncAccessDecision.java b/plugins/src/main/java/com/google/fhir/gateway/plugin/SyncAccessDecision.java new file mode 100755 index 00000000..bcb53aa8 --- /dev/null +++ b/plugins/src/main/java/com/google/fhir/gateway/plugin/SyncAccessDecision.java @@ -0,0 +1,482 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway.plugin; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.IParser; +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.server.exceptions.ForbiddenOperationException; +import com.google.common.annotations.VisibleForTesting; +import com.google.fhir.gateway.ExceptionUtil; +import com.google.fhir.gateway.ProxyConstants; +import com.google.fhir.gateway.interfaces.AccessDecision; +import com.google.fhir.gateway.interfaces.RequestDetailsReader; +import com.google.fhir.gateway.interfaces.RequestMutation; +import com.google.gson.Gson; +import java.io.FileReader; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import lombok.Getter; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpResponse; +import org.apache.http.impl.client.BasicResponseHandler; +import org.apache.http.util.TextUtils; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.ListResource; +import org.hl7.fhir.r4.model.OperationOutcome; +import org.hl7.fhir.r4.model.Resource; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SyncAccessDecision implements AccessDecision { + public static final String SYNC_FILTER_IGNORE_RESOURCES_FILE_ENV = + "SYNC_FILTER_IGNORE_RESOURCES_FILE"; + public static final String MATCHES_ANY_VALUE = "ANY_VALUE"; + private static final Logger logger = LoggerFactory.getLogger(SyncAccessDecision.class); + private static final int LENGTH_OF_SEARCH_PARAM_AND_EQUALS = 5; + private final String syncStrategy; + private final String applicationId; + private final boolean accessGranted; + private final List careTeamIds; + private final List locationIds; + private final List organizationIds; + private final List roles; + private IgnoredResourcesConfig config; + private String keycloakUUID; + private Gson gson = new Gson(); + private FhirContext fhirR4Context = FhirContext.forR4(); + private IParser fhirR4JsonParser = fhirR4Context.newJsonParser(); + private IGenericClient fhirR4Client; + + private PractitionerDetailsEndpointHelper practitionerDetailsEndpointHelper; + + public SyncAccessDecision( + String keycloakUUID, + String applicationId, + boolean accessGranted, + List locationIds, + List careTeamIds, + List organizationIds, + String syncStrategy, + List roles) { + this.keycloakUUID = keycloakUUID; + this.applicationId = applicationId; + this.accessGranted = accessGranted; + this.careTeamIds = careTeamIds; + this.locationIds = locationIds; + this.organizationIds = organizationIds; + this.syncStrategy = syncStrategy; + this.config = getSkippedResourcesConfigs(); + this.roles = roles; + try { + setFhirR4Client( + fhirR4Context.newRestfulGenericClient( + System.getenv(PermissionAccessChecker.Factory.PROXY_TO_ENV))); + } catch (NullPointerException e) { + logger.error(e.getMessage()); + } + + this.practitionerDetailsEndpointHelper = new PractitionerDetailsEndpointHelper(fhirR4Client); + } + + @Override + public boolean canAccess() { + return accessGranted; + } + + @Override + public RequestMutation getRequestMutation(RequestDetailsReader requestDetailsReader) { + + RequestMutation requestMutation = null; + if (isSyncUrl(requestDetailsReader)) { + if (locationIds.isEmpty() && careTeamIds.isEmpty() && organizationIds.isEmpty()) { + + ForbiddenOperationException forbiddenOperationException = + new ForbiddenOperationException( + "User un-authorized to " + + requestDetailsReader.getRequestType() + + " /" + + requestDetailsReader.getRequestPath() + + ". User assignment or sync strategy not configured correctly"); + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, forbiddenOperationException.getMessage(), forbiddenOperationException); + } + + // Skip app-wide global resource requests + if (!shouldSkipDataFiltering(requestDetailsReader)) { + List syncFilterParameterValues = + addSyncFilters(getSyncTags(locationIds, careTeamIds, organizationIds)); + requestMutation = + RequestMutation.builder() + .queryParams( + Map.of( + ProxyConstants.TAG_SEARCH_PARAM, + Arrays.asList(StringUtils.join(syncFilterParameterValues, ",")))) + .build(); + } + } + + return requestMutation; + } + + /** + * Adds filters to the {@link RequestDetailsReader} for the _tag property to allow filtering by + * specific code-url-values that match specific locations, teams or organisations + * + * @param syncTags + * @return the extra query Parameter values + */ + private List addSyncFilters(Map syncTags) { + List paramValues = new ArrayList<>(); + + for (var entry : syncTags.entrySet()) { + paramValues.add(PractitionerDetailsEndpointHelper.createSearchTagValues(entry)); + } + + return paramValues; + } + + /** NOTE: Always return a null whenever you want to skip post-processing */ + @Override + public String postProcess(RequestDetailsReader request, HttpResponse response) + throws IOException { + + String resultContent = null; + Resource resultContentBundle; + String gatewayMode = request.getHeader(Constants.FHIR_GATEWAY_MODE); + + if (StringUtils.isNotBlank(gatewayMode)) { + + resultContent = new BasicResponseHandler().handleResponse(response); + IBaseResource responseResource = fhirR4JsonParser.parseResource(resultContent); + + switch (gatewayMode) { + case Constants.LIST_ENTRIES: + resultContentBundle = postProcessModeListEntries(responseResource); + break; + + default: + String exceptionMessage = + "The FHIR Gateway Mode header is configured with an un-recognized value of \'" + + gatewayMode + + '\''; + OperationOutcome operationOutcome = createOperationOutcome(exceptionMessage); + + resultContentBundle = operationOutcome; + } + + if (resultContentBundle != null) + resultContent = fhirR4JsonParser.encodeResourceToString(resultContentBundle); + } + + if (includeAttributedPractitioners(request.getRequestPath())) { + Bundle practitionerDetailsBundle = + this.practitionerDetailsEndpointHelper.getSupervisorPractitionerDetailsByKeycloakId( + keycloakUUID); + resultContent = fhirR4JsonParser.encodeResourceToString(practitionerDetailsBundle); + } + + return resultContent; + } + + private boolean includeAttributedPractitioners(String requestPath) { + return Constants.SYNC_STRATEGY_LOCATION.equalsIgnoreCase(syncStrategy) + && roles.contains(Constants.ROLE_SUPERVISOR) + && Constants.ENDPOINT_PRACTITIONER_DETAILS.equals(requestPath); + } + + @NotNull + private static OperationOutcome createOperationOutcome(String exception) { + OperationOutcome operationOutcome = new OperationOutcome(); + OperationOutcome.OperationOutcomeIssueComponent operationOutcomeIssueComponent = + new OperationOutcome.OperationOutcomeIssueComponent(); + operationOutcomeIssueComponent.setSeverity(OperationOutcome.IssueSeverity.ERROR); + operationOutcomeIssueComponent.setCode(OperationOutcome.IssueType.PROCESSING); + operationOutcomeIssueComponent.setDiagnostics(exception); + operationOutcome.setIssue(Arrays.asList(operationOutcomeIssueComponent)); + return operationOutcome; + } + + @NotNull + private static Bundle processListEntriesGatewayModeByListResource( + ListResource responseListResource) { + Bundle requestBundle = new Bundle(); + requestBundle.setType(Bundle.BundleType.BATCH); + + for (ListResource.ListEntryComponent listEntryComponent : responseListResource.getEntry()) { + requestBundle.addEntry( + createBundleEntryComponent( + Bundle.HTTPVerb.GET, listEntryComponent.getItem().getReference(), null)); + } + return requestBundle; + } + + private Bundle processListEntriesGatewayModeByBundle(IBaseResource responseResource) { + Bundle requestBundle = new Bundle(); + requestBundle.setType(Bundle.BundleType.BATCH); + + List bundleEntryComponentList = + ((Bundle) responseResource) + .getEntry().stream() + .filter(it -> it.getResource() instanceof ListResource) + .flatMap( + bundleEntryComponent -> + ((ListResource) bundleEntryComponent.getResource()).getEntry().stream()) + .map( + listEntryComponent -> + createBundleEntryComponent( + Bundle.HTTPVerb.GET, listEntryComponent.getItem().getReference(), null)) + .collect(Collectors.toList()); + + return requestBundle.setEntry(bundleEntryComponentList); + } + + @NotNull + private static Bundle.BundleEntryComponent createBundleEntryComponent( + Bundle.HTTPVerb method, String requestPath, @Nullable String condition) { + + Bundle.BundleEntryComponent bundleEntryComponent = new Bundle.BundleEntryComponent(); + bundleEntryComponent.setRequest( + new Bundle.BundleEntryRequestComponent() + .setMethod(method) + .setUrl(requestPath) + .setIfMatch(condition)); + + return bundleEntryComponent; + } + + /** + * Generates a Bundle result from making a batch search request with the contained entries in the + * List as parameters + * + * @param responseResource FHIR Resource result returned byt the HTTPResponse + * @return String content of the result Bundle + */ + private Bundle postProcessModeListEntries(IBaseResource responseResource) { + + Bundle requestBundle = null; + + if (responseResource instanceof ListResource && ((ListResource) responseResource).hasEntry()) { + + requestBundle = processListEntriesGatewayModeByListResource((ListResource) responseResource); + + } else if (responseResource instanceof Bundle) { + + requestBundle = processListEntriesGatewayModeByBundle(responseResource); + } + + return fhirR4Client.transaction().withBundle(requestBundle).execute(); + } + + /** + * Generates a map of Code.url to multiple Code.Value which contains all the possible filters that + * will be used in syncing + * + * @param locationIds + * @param careTeamIds + * @param organizationIds + * @return Pair of URL to [Code.url, [Code.Value]] map. The URL is complete url + */ + private Map getSyncTags( + List locationIds, List careTeamIds, List organizationIds) { + StringBuilder sb = new StringBuilder(); + Map map = new HashMap<>(); + + sb.append(ProxyConstants.TAG_SEARCH_PARAM); + sb.append(ProxyConstants.Literals.EQUALS); + + addTags(ProxyConstants.LOCATION_TAG_URL, locationIds, map, sb); + addTags(ProxyConstants.ORGANISATION_TAG_URL, organizationIds, map, sb); + addTags(ProxyConstants.CARE_TEAM_TAG_URL, careTeamIds, map, sb); + + return map; + } + + private void addTags( + String tagUrl, + List values, + Map map, + StringBuilder urlStringBuilder) { + int len = values.size(); + if (len > 0) { + if (urlStringBuilder.length() + != (ProxyConstants.TAG_SEARCH_PARAM + ProxyConstants.Literals.EQUALS).length()) { + urlStringBuilder.append(ProxyConstants.PARAM_VALUES_SEPARATOR); + } + + map.put(tagUrl, values.toArray(new String[0])); + + int i = 0; + for (String tagValue : values) { + urlStringBuilder.append(tagUrl); + urlStringBuilder.append(ProxyConstants.CODE_URL_VALUE_SEPARATOR); + urlStringBuilder.append(tagValue); + + if (i != len - 1) { + urlStringBuilder.append(ProxyConstants.PARAM_VALUES_SEPARATOR); + } + i++; + } + } + } + + private boolean isSyncUrl(RequestDetailsReader requestDetailsReader) { + if (requestDetailsReader.getRequestType() == RequestTypeEnum.GET + && !TextUtils.isEmpty(requestDetailsReader.getResourceName())) { + String requestPath = requestDetailsReader.getRequestPath(); + return isResourceTypeRequest( + requestPath.replace(requestDetailsReader.getFhirServerBase(), "")); + } + + return false; + } + + private boolean isResourceTypeRequest(String requestPath) { + if (!TextUtils.isEmpty(requestPath)) { + String[] sections = requestPath.split(ProxyConstants.HTTP_URL_SEPARATOR); + + return sections.length == 1 || (sections.length == 2 && TextUtils.isEmpty(sections[1])); + } + + return false; + } + + @VisibleForTesting + protected IgnoredResourcesConfig getIgnoredResourcesConfigFileConfiguration(String configFile) { + if (configFile != null && !configFile.isEmpty()) { + try { + config = gson.fromJson(new FileReader(configFile), IgnoredResourcesConfig.class); + if (config == null || config.entries == null) { + throw new IllegalArgumentException("A map with a single `entries` array expected!"); + } + for (IgnoredResourcesConfig entry : config.entries) { + if (entry.getPath() == null) { + throw new IllegalArgumentException("Allow-list entries should have a path."); + } + } + + } catch (IOException e) { + logger.error("IO error while reading sync-filter skip-list config file {}", configFile); + } + } + + return config; + } + + @VisibleForTesting + protected IgnoredResourcesConfig getSkippedResourcesConfigs() { + return getIgnoredResourcesConfigFileConfiguration( + System.getenv(SYNC_FILTER_IGNORE_RESOURCES_FILE_ENV)); + } + + /** + * This method checks the request to ensure the path, request type and parameters match values in + * the hapi_sync_filter_ignored_queries configuration + */ + private boolean shouldSkipDataFiltering(RequestDetailsReader requestDetailsReader) { + if (config == null) return false; + + for (IgnoredResourcesConfig entry : config.entries) { + + if (!entry.getPath().equals(requestDetailsReader.getRequestPath())) { + continue; + } + + if (entry.getMethodType() != null + && !entry.getMethodType().equals(requestDetailsReader.getRequestType().name())) { + continue; + } + + for (Map.Entry expectedParam : entry.getQueryParams().entrySet()) { + String[] actualQueryValue = + requestDetailsReader.getParameters().get(expectedParam.getKey()); + + if (actualQueryValue == null) { + return true; + } + + if (MATCHES_ANY_VALUE.equals(expectedParam.getValue())) { + return true; + } else { + if (actualQueryValue.length != 1) { + // We currently do not support multivalued query params in skip-lists. + return false; + } + + if (expectedParam.getValue() instanceof List) { + return CollectionUtils.isEqualCollection( + (List) expectedParam.getValue(), Arrays.asList(actualQueryValue[0].split(","))); + + } else if (actualQueryValue[0].equals(expectedParam.getValue())) { + return true; + } + } + } + } + return false; + } + + @VisibleForTesting + protected void setSkippedResourcesConfig(IgnoredResourcesConfig config) { + this.config = config; + } + + @VisibleForTesting + protected void setFhirR4Context(FhirContext fhirR4Context) { + this.fhirR4Context = fhirR4Context; + } + + @VisibleForTesting + public void setFhirR4Client(IGenericClient fhirR4Client) { + this.fhirR4Client = fhirR4Client; + } + + class IgnoredResourcesConfig { + @Getter List entries; + @Getter private String path; + @Getter private String methodType; + @Getter private Map queryParams; + + @Override + public String toString() { + return "SkippedFilesConfig{" + + methodType + + " path=" + + path + + " fhirResources=" + + Arrays.toString(queryParams.entrySet().toArray()) + + '}'; + } + } + + public static final class Constants { + public static final String FHIR_GATEWAY_MODE = "fhir-gateway-mode"; + public static final String LIST_ENTRIES = "list-entries"; + public static final String ROLE_SUPERVISOR = "SUPERVISOR"; + public static final String ENDPOINT_PRACTITIONER_DETAILS = "practitioner-details"; + public static final String SYNC_STRATEGY_LOCATION = "Location"; + } +} diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateListTest.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateListTest.java index e42498d0..0959f730 100644 --- a/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateListTest.java +++ b/plugins/src/test/java/com/google/fhir/gateway/plugin/AccessGrantedAndUpdateListTest.java @@ -18,6 +18,7 @@ import ca.uhn.fhir.context.FhirContext; import com.google.common.io.Resources; import com.google.fhir.gateway.HttpFhirClient; +import com.google.fhir.gateway.interfaces.RequestDetailsReader; import java.io.IOException; import java.net.URL; import java.nio.charset.StandardCharsets; @@ -39,6 +40,9 @@ public class AccessGrantedAndUpdateListTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) private HttpResponse responseMock; + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private RequestDetailsReader requestDetailsMock; + private static final FhirContext fhirContext = FhirContext.forR4(); private AccessGrantedAndUpdateList testInstance; @@ -55,7 +59,7 @@ public void postProcessNewPatientPut() throws IOException { testInstance = AccessGrantedAndUpdateList.forPatientResource( TEST_LIST_ID, httpFhirClientMock, fhirContext); - testInstance.postProcess(responseMock); + testInstance.postProcess(requestDetailsMock, responseMock); } @Test @@ -63,6 +67,6 @@ public void postProcessNewPatientPost() throws IOException { testInstance = AccessGrantedAndUpdateList.forPatientResource( TEST_LIST_ID, httpFhirClientMock, fhirContext); - testInstance.postProcess(responseMock); + testInstance.postProcess(requestDetailsMock, responseMock); } } diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/PermissionAccessCheckerTest.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/PermissionAccessCheckerTest.java new file mode 100755 index 00000000..99844324 --- /dev/null +++ b/plugins/src/test/java/com/google/fhir/gateway/plugin/PermissionAccessCheckerTest.java @@ -0,0 +1,462 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway.plugin; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.Mockito.when; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.google.common.io.Resources; +import com.google.fhir.gateway.PatientFinderImp; +import com.google.fhir.gateway.interfaces.AccessChecker; +import com.google.fhir.gateway.interfaces.RequestDetailsReader; +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.hl7.fhir.r4.model.Enumerations; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +@Ignore +public class PermissionAccessCheckerTest { + + @Mock protected DecodedJWT jwtMock; + + @Mock protected Claim claimMock; + + // TODO consider making a real request object from a URL string to avoid over-mocking. + @Mock protected RequestDetailsReader requestMock; + + // Note this is an expensive class to instantiate, so we only do this once for all tests. + protected static final FhirContext fhirContext = FhirContext.forR4(); + + void setUpFhirBundle(String filename) throws IOException { + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + URL url = Resources.getResource(filename); + byte[] obsBytes = Resources.toByteArray(url); + when(requestMock.loadRequestContents()).thenReturn(obsBytes); + } + + @Before + public void setUp() throws IOException { + when(jwtMock.getClaim(PermissionAccessChecker.Factory.REALM_ACCESS_CLAIM)) + .thenReturn(claimMock); + when(jwtMock.getClaim(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM)) + .thenReturn(claimMock); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + } + + protected AccessChecker getInstance() { + return new PermissionAccessChecker.Factory() + .create(jwtMock, null, fhirContext, PatientFinderImp.getInstance(fhirContext)); + } + + @Test + public void testManagePatientRoleCanAccessGetPatient() throws IOException { + // Query: GET/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); + when(claimMock.asMap()).thenReturn(map); + when(claimMock.asString()).thenReturn("ecbis-saa"); + + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testGetPatientRoleCanAccessGetPatient() throws IOException { + // Query: GET/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("GET_PATIENT")); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testGetPatientWithoutRoleCannotAccessGetPatient() throws IOException { + // Query: GET/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.GET); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(false)); + } + + @Test + public void testDeletePatientRoleCanAccessDeletePatient() throws IOException { + // Query: DELETE/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("DELETE_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.DELETE); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testManagePatientRoleCanAccessDeletePatient() throws IOException { + // Query: DELETE/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.DELETE); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testDeletePatientWithoutRoleCannotAccessDeletePatient() throws IOException { + // Query: DELETE/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.DELETE); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(false)); + } + + @Test + public void testPutWithManagePatientRoleCanAccessPutPatient() throws IOException { + // Query: PUT/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getResourceName()).thenReturn("Patient"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.PUT); + + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + + @Test + public void testPutPatientWithRoleCanAccessPutPatient() throws IOException { + // Query: PUT/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("PUT_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getResourceName()).thenReturn("Patient"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.PUT); + + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + + @Test + public void testPutPatientWithoutRoleCannotAccessPutPatient() throws IOException { + // Query: PUT/PID + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getResourceName()).thenReturn("Patient"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.PUT); + + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); + } + + @Test + public void testPostPatientWithRoleCanAccessPostPatient() throws IOException { + // Query: /POST + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("POST_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getResourceName()).thenReturn("Patient"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + + @Test + public void testPostPatientWithoutRoleCannotAccessPostPatient() throws IOException { + // Query: /POST + setUpFhirBundle("test_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + when(requestMock.getResourceName()).thenReturn(Enumerations.ResourceType.PATIENT.name()); + when(requestMock.getResourceName()).thenReturn("Patient"); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + AccessChecker testInstance = getInstance(); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); + } + + @Test + public void testManageResourceRoleCanAccessBundlePutResources() throws IOException { + setUpFhirBundle("bundle_transaction_put_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testPutResourceRoleCanAccessBundlePutResources() throws IOException { + setUpFhirBundle("bundle_transaction_put_patient.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("PUT_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testDeleteResourceRoleCanAccessBundleDeleteResources() throws IOException { + setUpFhirBundle("bundle_transaction_delete.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("DELETE_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testWithCorrectRolesCanAccessDifferentTypeBundleResources() throws IOException { + setUpFhirBundle("bundle_transaction_patient_and_non_patients.json"); + + Map map = new HashMap<>(); + map.put( + PermissionAccessChecker.Factory.ROLES, + Arrays.asList("PUT_PATIENT", "PUT_OBSERVATION", "PUT_ENCOUNTER")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testManageResourcesCanAccessDifferentTypeBundleResources() throws IOException { + setUpFhirBundle("bundle_transaction_patient_and_non_patients.json"); + + Map map = new HashMap<>(); + map.put( + PermissionAccessChecker.Factory.ROLES, + Arrays.asList("MANAGE_PATIENT", "MANAGE_OBSERVATION", "MANAGE_ENCOUNTER")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testManageResourcesWithMissingRoleCannotAccessDifferentTypeBundleResources() + throws IOException { + setUpFhirBundle("bundle_transaction_patient_and_non_patients.json"); + + Map map = new HashMap<>(); + map.put( + PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT", "MANAGE_ENCOUNTER")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + AccessChecker testInstance = getInstance(); + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(false)); + } + + @Test(expected = InvalidRequestException.class) + public void testBundleResourceNonTransactionTypeThrowsException() throws IOException { + setUpFhirBundle("bundle_empty.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList()); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + AccessChecker testInstance = getInstance(); + Assert.assertFalse(testInstance.checkAccess(requestMock).canAccess()); + } + + @Test + public void testAccessGrantedWhenManageResourcePresentForTypeBundleResources() + throws IOException { + setUpFhirBundle("test_bundle_transaction.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("MANAGE_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + PermissionAccessChecker testInstance = Mockito.spy((PermissionAccessChecker) getInstance()); + when(testInstance.isDevMode()).thenReturn(true); + + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testAccessGrantedWhenAllRolesPresentForTypeBundleResources() throws IOException { + setUpFhirBundle("test_bundle_transaction.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("PUT_PATIENT", "POST_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + PermissionAccessChecker testInstance = Mockito.spy((PermissionAccessChecker) getInstance()); + when(testInstance.isDevMode()).thenReturn(true); + + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(true)); + } + + @Test + public void testAccessDeniedWhenSingleRoleMissingForTypeBundleResources() throws IOException { + setUpFhirBundle("test_bundle_transaction.json"); + + Map map = new HashMap<>(); + map.put(PermissionAccessChecker.Factory.ROLES, Arrays.asList("PUT_PATIENT")); + map.put(PermissionAccessChecker.Factory.FHIR_CORE_APPLICATION_ID_CLAIM, "ecbis-saa"); + when(claimMock.asMap()).thenReturn(map); + + when(requestMock.getResourceName()).thenReturn(null); + when(requestMock.getRequestType()).thenReturn(RequestTypeEnum.POST); + + PermissionAccessChecker testInstance = Mockito.spy((PermissionAccessChecker) getInstance()); + when(testInstance.isDevMode()).thenReturn(true); + + boolean canAccess = testInstance.checkAccess(requestMock).canAccess(); + + assertThat(canAccess, equalTo(false)); + } +} diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/SyncAccessDecisionTest.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/SyncAccessDecisionTest.java new file mode 100755 index 00000000..983b3ded --- /dev/null +++ b/plugins/src/test/java/com/google/fhir/gateway/plugin/SyncAccessDecisionTest.java @@ -0,0 +1,565 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway.plugin; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.gclient.ITransaction; +import ca.uhn.fhir.rest.gclient.ITransactionTyped; +import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; +import com.google.common.collect.Maps; +import com.google.common.io.Resources; +import com.google.fhir.gateway.ProxyConstants; +import com.google.fhir.gateway.interfaces.RequestDetailsReader; +import com.google.fhir.gateway.interfaces.RequestMutation; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.HttpResponse; +import org.hl7.fhir.r4.model.Bundle; +import org.hl7.fhir.r4.model.ListResource; +import org.junit.After; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class SyncAccessDecisionTest { + + private List locationIds = new ArrayList<>(); + + private List careTeamIds = new ArrayList<>(); + + private List organisationIds = new ArrayList<>(); + + private List userRoles = new ArrayList<>(); + + private SyncAccessDecision testInstance; + + @Test + public void + preprocessShouldAddAllFiltersWhenIdsForLocationsOrganisationsAndCareTeamsAreProvided() { + locationIds.addAll(Arrays.asList("my-location-id", "my-location-id2")); + careTeamIds.add("my-careteam-id"); + organisationIds.add("my-organization-id"); + + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("Patient"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); + requestDetails.setRequestPath("Patient"); + + // Call the method under testing + RequestMutation mutatedRequest = + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + + List allIds = new ArrayList<>(); + allIds.addAll(locationIds); + allIds.addAll(organisationIds); + allIds.addAll(careTeamIds); + + List locationTagToValuesList = new ArrayList<>(); + + for (String locationId : locationIds) { + Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); + Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); + + locationTagToValuesList.add(ProxyConstants.LOCATION_TAG_URL + "|" + locationId); + } + + Assert.assertTrue( + mutatedRequest + .getQueryParams() + .get("_tag") + .get(0) + .contains(StringUtils.join(locationTagToValuesList, ","))); + + List careteamTagToValuesList = new ArrayList<>(); + + for (String careTeamId : careTeamIds) { + Assert.assertFalse(requestDetails.getCompleteUrl().contains(careTeamId)); + Assert.assertFalse(requestDetails.getRequestPath().contains(careTeamId)); + careteamTagToValuesList.add(ProxyConstants.LOCATION_TAG_URL + "|" + careTeamId); + } + + Assert.assertTrue( + mutatedRequest + .getQueryParams() + .get("_tag") + .get(0) + .contains(StringUtils.join(locationTagToValuesList, ","))); + + for (String organisationId : organisationIds) { + Assert.assertFalse(requestDetails.getCompleteUrl().contains(organisationId)); + Assert.assertFalse(requestDetails.getRequestPath().contains(organisationId)); + } + + Assert.assertTrue( + mutatedRequest + .getQueryParams() + .get("_tag") + .get(0) + .contains( + StringUtils.join( + organisationIds, "," + ProxyConstants.ORGANISATION_TAG_URL + "|"))); + } + + @Test + public void preProcessShouldAddLocationIdFiltersWhenUserIsAssignedToLocationsOnly() + throws IOException { + locationIds.add("locationid12"); + locationIds.add("locationid2"); + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("Patient"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); + requestDetails.setRequestPath("Patient"); + + RequestMutation mutatedRequest = + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + + for (String locationId : locationIds) { + Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); + Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); + } + Assert.assertTrue( + mutatedRequest + .getQueryParams() + .get("_tag") + .get(0) + .contains(StringUtils.join(locationIds, "," + ProxyConstants.LOCATION_TAG_URL + "|"))); + + for (String param : mutatedRequest.getQueryParams().get("_tag")) { + Assert.assertFalse(param.contains(ProxyConstants.CARE_TEAM_TAG_URL)); + Assert.assertFalse(param.contains(ProxyConstants.ORGANISATION_TAG_URL)); + } + } + + @Test + public void preProcessShouldAddCareTeamIdFiltersWhenUserIsAssignedToCareTeamsOnly() + throws IOException { + careTeamIds.add("careteamid1"); + careTeamIds.add("careteamid2"); + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("Patient"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); + requestDetails.setRequestPath("Patient"); + + RequestMutation mutatedRequest = + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + + for (String locationId : careTeamIds) { + Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); + Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); + } + + Assert.assertTrue( + mutatedRequest + .getQueryParams() + .get("_tag") + .get(0) + .contains(StringUtils.join(careTeamIds, "," + ProxyConstants.CARE_TEAM_TAG_URL + "|"))); + + for (String param : mutatedRequest.getQueryParams().get("_tag")) { + Assert.assertFalse(param.contains(ProxyConstants.LOCATION_TAG_URL)); + Assert.assertFalse(param.contains(ProxyConstants.ORGANISATION_TAG_URL)); + } + } + + @Test + public void preProcessShouldAddOrganisationIdFiltersWhenUserIsAssignedToOrganisationsOnly() + throws IOException { + organisationIds.add("organizationid1"); + organisationIds.add("organizationid2"); + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("Patient"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); + requestDetails.setRequestPath("Patient"); + + RequestMutation mutatedRequest = + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + + for (String locationId : careTeamIds) { + Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); + Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); + Assert.assertTrue( + mutatedRequest + .getQueryParams() + .get("_tag") + .contains(ProxyConstants.ORGANISATION_TAG_URL + "|" + locationId)); + } + + for (String param : mutatedRequest.getQueryParams().get("_tag")) { + Assert.assertFalse(param.contains(ProxyConstants.LOCATION_TAG_URL)); + Assert.assertFalse(param.contains(ProxyConstants.CARE_TEAM_TAG_URL)); + } + } + + @Test + public void preProcessShouldAddFiltersWhenResourceNotInSyncFilterIgnoredResourcesFile() { + organisationIds.add("organizationid1"); + organisationIds.add("organizationid2"); + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("Patient"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); + requestDetails.setRequestPath("Patient"); + + RequestMutation mutatedRequest = + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + + for (String locationId : organisationIds) { + Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); + Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); + Assert.assertEquals(1, mutatedRequest.getQueryParams().size()); + } + Assert.assertTrue( + mutatedRequest + .getQueryParams() + .get("_tag") + .get(0) + .contains( + StringUtils.join( + organisationIds, "," + ProxyConstants.ORGANISATION_TAG_URL + "|"))); + } + + @Test + public void preProcessShouldSkipAddingFiltersWhenResourceInSyncFilterIgnoredResourcesFile() { + organisationIds.add("organizationid1"); + organisationIds.add("organizationid2"); + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("Questionnaire"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + requestDetails.setCompleteUrl("https://smartregister.org/fhir/Questionnaire"); + requestDetails.setRequestPath("Questionnaire"); + + RequestMutation mutatedRequest = + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + + for (String locationId : organisationIds) { + Assert.assertFalse(requestDetails.getCompleteUrl().contains(locationId)); + Assert.assertFalse(requestDetails.getRequestPath().contains(locationId)); + Assert.assertNull(mutatedRequest); + } + } + + @Test + public void + preProcessShouldSkipAddingFiltersWhenSearchResourceByIdsInSyncFilterIgnoredResourcesFile() { + organisationIds.add("organizationid1"); + organisationIds.add("organizationid2"); + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("StructureMap"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + List queryStringParamValues = Arrays.asList("1000", "2000", "3000"); + requestDetails.setCompleteUrl( + "https://smartregister.org/fhir/StructureMap?_id=" + + StringUtils.join(queryStringParamValues, ",")); + Assert.assertEquals( + "https://smartregister.org/fhir/StructureMap?_id=1000,2000,3000", + requestDetails.getCompleteUrl()); + requestDetails.setRequestPath("StructureMap"); + + Map params = Maps.newHashMap(); + params.put("_id", new String[] {StringUtils.join(queryStringParamValues, ",")}); + requestDetails.setParameters(params); + + RequestMutation mutatedRequest = + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + + Assert.assertNull(mutatedRequest); + } + + @Test + public void + preProcessShouldAddFiltersWhenSearchResourceByIdsDoNotMatchSyncFilterIgnoredResources() { + organisationIds.add("organizationid1"); + organisationIds.add("organizationid2"); + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("StructureMap"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + List queryStringParamValues = Arrays.asList("1000", "2000"); + requestDetails.setCompleteUrl( + "https://smartregister.org/fhir/StructureMap?_id=" + + StringUtils.join(queryStringParamValues, ",")); + Assert.assertEquals( + "https://smartregister.org/fhir/StructureMap?_id=1000,2000", + requestDetails.getCompleteUrl()); + requestDetails.setRequestPath("StructureMap"); + + Map params = Maps.newHashMap(); + params.put("_id", new String[] {StringUtils.join(queryStringParamValues, ",")}); + requestDetails.setParameters(params); + + RequestMutation mutatedRequest = + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + + List searchParamArrays = + mutatedRequest.getQueryParams().get(ProxyConstants.TAG_SEARCH_PARAM); + Assert.assertNotNull(searchParamArrays); + + Assert.assertTrue( + searchParamArrays + .get(0) + .contains( + StringUtils.join( + organisationIds, "," + ProxyConstants.ORGANISATION_TAG_URL + "|"))); + } + + @Test(expected = RuntimeException.class) + public void preprocessShouldThrowRuntimeExceptionWhenNoSyncStrategyFilterIsProvided() { + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetails requestDetails = new ServletRequestDetails(); + requestDetails.setRequestType(RequestTypeEnum.GET); + requestDetails.setRestOperationType(RestOperationTypeEnum.SEARCH_TYPE); + requestDetails.setResourceName("Patient"); + requestDetails.setRequestPath("Patient"); + requestDetails.setFhirServerBase("https://smartregister.org/fhir"); + requestDetails.setCompleteUrl("https://smartregister.org/fhir/Patient"); + + // Call the method under testing + testInstance.getRequestMutation(new TestRequestDetailsToReader(requestDetails)); + } + + @Test + public void testPostProcessWithListModeHeaderShouldFetchListEntriesBundle() throws IOException { + locationIds.add("Location-1"); + testInstance = Mockito.spy(createSyncAccessDecisionTestInstance()); + + FhirContext fhirR4Context = mock(FhirContext.class); + IGenericClient iGenericClient = mock(IGenericClient.class); + ITransaction iTransaction = mock(ITransaction.class); + ITransactionTyped iClientExecutable = mock(ITransactionTyped.class); + testInstance.setFhirR4Client(iGenericClient); + testInstance.setFhirR4Context(fhirR4Context); + + Mockito.when(iGenericClient.transaction()).thenReturn(iTransaction); + Mockito.when(iTransaction.withBundle(any(Bundle.class))).thenReturn(iClientExecutable); + + Bundle resultBundle = new Bundle(); + resultBundle.setType(Bundle.BundleType.BATCHRESPONSE); + resultBundle.setId("bundle-result-id"); + + Mockito.when(iClientExecutable.execute()).thenReturn(resultBundle); + + ArgumentCaptor bundleArgumentCaptor = ArgumentCaptor.forClass(Bundle.class); + + testInstance.setFhirR4Context(fhirR4Context); + + RequestDetailsReader requestDetailsSpy = Mockito.mock(RequestDetailsReader.class); + + Mockito.when(requestDetailsSpy.getHeader(SyncAccessDecision.Constants.FHIR_GATEWAY_MODE)) + .thenReturn(SyncAccessDecision.Constants.LIST_ENTRIES); + + URL listUrl = Resources.getResource("test_list_resource.json"); + String testListJson = Resources.toString(listUrl, StandardCharsets.UTF_8); + + HttpResponse fhirResponseMock = Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); + + TestUtil.setUpFhirResponseMock(fhirResponseMock, testListJson); + + String resultContent = testInstance.postProcess(requestDetailsSpy, fhirResponseMock); + + Mockito.verify(iTransaction).withBundle(bundleArgumentCaptor.capture()); + Bundle requestBundle = bundleArgumentCaptor.getValue(); + + // Verify modified request to the server + Assert.assertNotNull(requestBundle); + Assert.assertEquals(Bundle.BundleType.BATCH, requestBundle.getType()); + List requestBundleEntries = requestBundle.getEntry(); + Assert.assertEquals(2, requestBundleEntries.size()); + + Assert.assertEquals(Bundle.HTTPVerb.GET, requestBundleEntries.get(0).getRequest().getMethod()); + Assert.assertEquals( + "Group/proxy-list-entry-id-1", requestBundleEntries.get(0).getRequest().getUrl()); + + Assert.assertEquals(Bundle.HTTPVerb.GET, requestBundleEntries.get(1).getRequest().getMethod()); + Assert.assertEquals( + "Group/proxy-list-entry-id-2", requestBundleEntries.get(1).getRequest().getUrl()); + + // Verify returned result content from the server request + Assert.assertNotNull(resultContent); + Assert.assertEquals( + "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\"}", + resultContent); + } + + @Test + public void testPostProcessWithoutListModeHeaderShouldShouldReturnNull() throws IOException { + testInstance = createSyncAccessDecisionTestInstance(); + + RequestDetailsReader requestDetailsSpy = Mockito.mock(RequestDetailsReader.class); + Mockito.when(requestDetailsSpy.getHeader(SyncAccessDecision.Constants.FHIR_GATEWAY_MODE)) + .thenReturn(""); + + String resultContent = + testInstance.postProcess(requestDetailsSpy, Mockito.mock(HttpResponse.class)); + + // Verify no special Post-Processing happened + Assert.assertNull(resultContent); + } + + @Test + public void testPostProcessWithListModeHeaderSearchByTagShouldFetchListEntriesBundle() + throws IOException { + locationIds.add("Location-1"); + testInstance = Mockito.spy(createSyncAccessDecisionTestInstance()); + + FhirContext fhirR4Context = mock(FhirContext.class); + IGenericClient iGenericClient = mock(IGenericClient.class); + ITransaction iTransaction = mock(ITransaction.class); + ITransactionTyped iClientExecutable = mock(ITransactionTyped.class); + + Mockito.when(iGenericClient.transaction()).thenReturn(iTransaction); + Mockito.when(iTransaction.withBundle(any(Bundle.class))).thenReturn(iClientExecutable); + + Bundle resultBundle = new Bundle(); + resultBundle.setType(Bundle.BundleType.BATCHRESPONSE); + resultBundle.setId("bundle-result-id"); + + Mockito.when(iClientExecutable.execute()).thenReturn(resultBundle); + + ArgumentCaptor bundleArgumentCaptor = ArgumentCaptor.forClass(Bundle.class); + + testInstance.setFhirR4Context(fhirR4Context); + + RequestDetailsReader requestDetailsSpy = Mockito.mock(RequestDetailsReader.class); + + Mockito.when(requestDetailsSpy.getHeader(SyncAccessDecision.Constants.FHIR_GATEWAY_MODE)) + .thenReturn(SyncAccessDecision.Constants.LIST_ENTRIES); + + URL listUrl = Resources.getResource("test_list_resource.json"); + String testListJson = Resources.toString(listUrl, StandardCharsets.UTF_8); + + FhirContext realFhirContext = FhirContext.forR4(); + ListResource listResource = + (ListResource) realFhirContext.newJsonParser().parseResource(testListJson); + + Bundle bundle = new Bundle(); + Bundle.BundleEntryComponent bundleEntryComponent = new Bundle.BundleEntryComponent(); + bundleEntryComponent.setResource(listResource); + bundle.setType(Bundle.BundleType.BATCHRESPONSE); + bundle.setEntry(Arrays.asList(bundleEntryComponent)); + + HttpResponse fhirResponseMock = Mockito.mock(HttpResponse.class, Answers.RETURNS_DEEP_STUBS); + + TestUtil.setUpFhirResponseMock( + fhirResponseMock, realFhirContext.newJsonParser().encodeResourceToString(bundle)); + + testInstance.setFhirR4Client(iGenericClient); + testInstance.setFhirR4Context(fhirR4Context); + String resultContent = testInstance.postProcess(requestDetailsSpy, fhirResponseMock); + + Mockito.verify(iTransaction).withBundle(bundleArgumentCaptor.capture()); + Bundle requestBundle = bundleArgumentCaptor.getValue(); + + // Verify modified request to the server + Assert.assertNotNull(requestBundle); + Assert.assertEquals(Bundle.BundleType.BATCH, requestBundle.getType()); + List requestBundleEntries = requestBundle.getEntry(); + Assert.assertEquals(2, requestBundleEntries.size()); + + Assert.assertEquals(Bundle.HTTPVerb.GET, requestBundleEntries.get(0).getRequest().getMethod()); + Assert.assertEquals( + "Group/proxy-list-entry-id-1", requestBundleEntries.get(0).getRequest().getUrl()); + + Assert.assertEquals(Bundle.HTTPVerb.GET, requestBundleEntries.get(1).getRequest().getMethod()); + Assert.assertEquals( + "Group/proxy-list-entry-id-2", requestBundleEntries.get(1).getRequest().getUrl()); + + // Verify returned result content from the server request + Assert.assertNotNull(resultContent); + Assert.assertEquals( + "{\"resourceType\":\"Bundle\",\"id\":\"bundle-result-id\",\"type\":\"batch-response\"}", + resultContent); + } + + @After + public void cleanUp() { + locationIds.clear(); + careTeamIds.clear(); + organisationIds.clear(); + } + + private SyncAccessDecision createSyncAccessDecisionTestInstance() { + SyncAccessDecision accessDecision = + new SyncAccessDecision( + "sample-keycloak-id", + "sample-application-id", + true, + locationIds, + careTeamIds, + organisationIds, + null, + userRoles); + + URL configFileUrl = Resources.getResource("hapi_sync_filter_ignored_queries.json"); + SyncAccessDecision.IgnoredResourcesConfig skippedDataFilterConfig = + accessDecision.getIgnoredResourcesConfigFileConfiguration(configFileUrl.getPath()); + accessDecision.setSkippedResourcesConfig(skippedDataFilterConfig); + return accessDecision; + } +} diff --git a/plugins/src/test/java/com/google/fhir/gateway/plugin/TestRequestDetailsToReader.java b/plugins/src/test/java/com/google/fhir/gateway/plugin/TestRequestDetailsToReader.java new file mode 100644 index 00000000..9f7e8b25 --- /dev/null +++ b/plugins/src/test/java/com/google/fhir/gateway/plugin/TestRequestDetailsToReader.java @@ -0,0 +1,106 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway.plugin; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import com.google.fhir.gateway.interfaces.RequestDetailsReader; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Map; +import org.hl7.fhir.instance.model.api.IIdType; + +// Note instances of this class are expected to be one per thread and this class is not thread-safe +// the same way the underlying `requestDetails` is not. +public class TestRequestDetailsToReader implements RequestDetailsReader { + private final RequestDetails requestDetails; + + TestRequestDetailsToReader(RequestDetails requestDetails) { + this.requestDetails = requestDetails; + } + + public String getRequestId() { + return requestDetails.getRequestId(); + } + + public Charset getCharset() { + return requestDetails.getCharset(); + } + + public String getCompleteUrl() { + return requestDetails.getCompleteUrl(); + } + + public FhirContext getFhirContext() { + // TODO: There might be a race condition in the underlying `getFhirContext`; check if this is + // true. Note the `myServer` object is shared between threads. + return requestDetails.getFhirContext(); + } + + public String getFhirServerBase() { + return requestDetails.getFhirServerBase(); + } + + public String getHeader(String name) { + return requestDetails.getHeader(name); + } + + public List getHeaders(String name) { + return requestDetails.getHeaders(name); + } + + public IIdType getId() { + return requestDetails.getId(); + } + + public String getOperation() { + return requestDetails.getOperation(); + } + + public Map getParameters() { + return requestDetails.getParameters(); + } + + public String getRequestPath() { + return requestDetails.getRequestPath(); + } + + public RequestTypeEnum getRequestType() { + return requestDetails.getRequestType(); + } + + public String getResourceName() { + return requestDetails.getResourceName(); + } + + public RestOperationTypeEnum getRestOperationType() { + return requestDetails.getRestOperationType(); + } + + public String getSecondaryOperation() { + return requestDetails.getSecondaryOperation(); + } + + public boolean isRespondGzip() { + return requestDetails.isRespondGzip(); + } + + public byte[] loadRequestContents() { + return requestDetails.loadRequestContents(); + } +} diff --git a/plugins/src/test/resources/hapi_sync_filter_ignored_queries.json b/plugins/src/test/resources/hapi_sync_filter_ignored_queries.json new file mode 100644 index 00000000..45a10ad5 --- /dev/null +++ b/plugins/src/test/resources/hapi_sync_filter_ignored_queries.json @@ -0,0 +1,29 @@ +{ + "entries": [ + { + "path": "Questionnaire", + "methodType": "GET", + "queryParams": { + "_id": "ANY_VALUE" + } + }, + { + "path": "List", + "methodType": "GET", + "queryParams": { + "_id": "ANY_VALUE" + } + }, + { + "path": "StructureMap", + "methodType": "GET", + "queryParams": { + "_id": [ + "1000", + "2000", + "3000" + ] + } + } + ] +} \ No newline at end of file diff --git a/plugins/src/test/resources/test_bundle_transaction.json b/plugins/src/test/resources/test_bundle_transaction.json new file mode 100644 index 00000000..54714c68 --- /dev/null +++ b/plugins/src/test/resources/test_bundle_transaction.json @@ -0,0 +1,61 @@ +{ + "resourceType": "Bundle", + "type": "transaction", + "entry": [ + { + "resource": { + "resourceType": "Patient", + "name": [ + { + "family": "Smith", + "given": [ + "Darcy" + ] + } + ], + "gender": "female", + "address": [ + { + "line": [ + "123 Main St." + ], + "city": "Anycity", + "state": "CA", + "postalCode": "12345" + } + ] + }, + "request": { + "method": "POST", + "url": "Patient" + } + }, { + "resource": { + "resourceType": "Patient", + "name": [ + { + "family": "Smith", + "given": [ + "Darcy" + ] + } + ], + "gender": "female", + "address": [ + { + "line": [ + "123 Main St." + ], + "city": "Anycity", + "state": "CA", + "postalCode": "12345" + } + ] + }, + "request": { + "method": "PUT", + "url": "Patient/be92a43f-de46-affa-b131-bbf9eea51140" + } + } + ] +} \ No newline at end of file diff --git a/plugins/src/test/resources/test_list_resource.json b/plugins/src/test/resources/test_list_resource.json new file mode 100644 index 00000000..6d384d29 --- /dev/null +++ b/plugins/src/test/resources/test_list_resource.json @@ -0,0 +1,35 @@ +{ + "resourceType": "List", + "id": "proxy-test-list-id", + "identifier": [ + { + "use": "official", + "value": "proxy-test-list-id" + } + ], + "status": "current", + "mode": "working", + "title": "Proxy Test List", + "code": { + "coding": [ + { + "system": "http://ona.io", + "code": "supply-chain", + "display": "Proxy Test List" + } + ], + "text": "My Proxy Test List" + }, + "entry": [ + { + "item": { + "reference": "Group/proxy-list-entry-id-1" + } + }, + { + "item": { + "reference": "Group/proxy-list-entry-id-2" + } + } + ] +} diff --git a/pom.xml b/pom.xml old mode 100644 new mode 100755 index 9d8d1333..ac6f1ee4 --- a/pom.xml +++ b/pom.xml @@ -27,7 +27,7 @@ com.google.fhir.gateway fhir-gateway - 0.2.1-SNAPSHOT + 0.1.32 pom FHIR Information Gateway @@ -60,11 +60,13 @@ with our sonatype.org credentials, it fails with a "Forbidden" message. --> - ossrh - https://oss.sonatype.org/service/local/staging/deploy/maven2/ + nexus-releases + https://oss.sonatype.org/service/local/staging/deploy/maven2 - ossrh + false + nexus-snapshots + Nexus Snapshots Repository https://oss.sonatype.org/content/repositories/snapshots @@ -72,7 +74,7 @@ 6.2.5 UTF-8 - 2.32.0 + 2.27.2 ${project.basedir} 11 11 @@ -139,6 +141,13 @@ test + + net.bytebuddy + byte-buddy + 1.14.3 + test + + diff --git a/resources/hapi_page_url_allowed_queries.json b/resources/hapi_page_url_allowed_queries.json index de947004..927b2c33 100644 --- a/resources/hapi_page_url_allowed_queries.json +++ b/resources/hapi_page_url_allowed_queries.json @@ -6,7 +6,25 @@ "_getpages": "ANY_VALUE" }, "allowExtraParams": true, + "allParamsRequired": false + }, + { + "path": "Composition", + "methodType": "GET", + "queryParams": { + "identifier":"ANY_VALUE" + }, + "allowExtraParams": true, + "allParamsRequired": true + }, + { + "path": "Binary", + "methodType": "GET", + "queryParams": { + "_id":"ANY_VALUE" + }, + "allowExtraParams": true, "allParamsRequired": true } ] -} \ No newline at end of file +} diff --git a/resources/hapi_sync_filter_ignored_queries.json b/resources/hapi_sync_filter_ignored_queries.json new file mode 100644 index 00000000..2283c436 --- /dev/null +++ b/resources/hapi_sync_filter_ignored_queries.json @@ -0,0 +1,46 @@ +{ + "entries": [ + { + "path": "Questionnaire", + "methodType": "GET", + "queryParams": { + "_id": "ANY_VALUE" + } + }, + { + "path": "StructureMap", + "methodType": "GET", + "queryParams": { + "_id": "ANY_VALUE" + } + }, + { + "path": "List", + "methodType": "GET", + "queryParams": { + "_id": "ANY_VALUE" + } + }, + { + "path": "PlanDefinition", + "methodType": "GET", + "queryParams": { + "_id": "ANY_VALUE" + } + }, + { + "path": "Library", + "methodType": "GET", + "queryParams": { + "_id": "ANY_VALUE" + } + }, + { + "path": "Measure", + "methodType": "GET", + "queryParams": { + "_id": "ANY_VALUE" + } + } + ] +} diff --git a/server/pom.xml b/server/pom.xml old mode 100644 new mode 100755 index 1e977371..c2738a8c --- a/server/pom.xml +++ b/server/pom.xml @@ -21,7 +21,7 @@ com.google.fhir.gateway fhir-gateway - 0.2.1-SNAPSHOT + 0.1.32 server @@ -78,6 +78,13 @@ test + + org.springframework + spring-test + ${spring.version} + test + + com.google.http-client @@ -137,6 +144,18 @@ 1.18.26 + + org.smartregister + fhir-common-utils + 0.0.10-SNAPSHOT + + + + ca.uhn.hapi.fhir + hapi-fhir-client + ${hapifhir_version} + + diff --git a/server/src/main/java/com/google/fhir/gateway/AllowedQueriesChecker.java b/server/src/main/java/com/google/fhir/gateway/AllowedQueriesChecker.java old mode 100644 new mode 100755 index a7184a94..1fa58076 --- a/server/src/main/java/com/google/fhir/gateway/AllowedQueriesChecker.java +++ b/server/src/main/java/com/google/fhir/gateway/AllowedQueriesChecker.java @@ -92,6 +92,29 @@ private boolean requestMatches(RequestDetailsReader requestDetails, AllowedQuery if (!Strings.isNullOrEmpty(entry.getRequestType()) && !requestDetails.getRequestType().name().equalsIgnoreCase(entry.getRequestType())) { + + if (!requestDetails.getRequestPath().endsWith(ProxyConstants.HTTP_URL_SEPARATOR) + && requestDetails.getRequestPath().contains(ProxyConstants.HTTP_URL_SEPARATOR) + && entry.getPath().endsWith(AllowedQueriesConfig.MATCHES_ANY_VALUE) + && entry + .getPath() + .equals( + requestDetails + .getRequestPath() + .substring( + 0, + requestDetails + .getRequestPath() + .lastIndexOf(ProxyConstants.HTTP_URL_SEPARATOR)) + + ProxyConstants.HTTP_URL_SEPARATOR + + AllowedQueriesConfig.MATCHES_ANY_VALUE)) { + } else { + return false; + } + } + + if (entry.getRequestType() != null + && !(entry.getRequestType().toUpperCase()).equals(requestDetails.getRequestType().name())) { return false; } diff --git a/server/src/main/java/com/google/fhir/gateway/AllowedQueriesConfig.java b/server/src/main/java/com/google/fhir/gateway/AllowedQueriesConfig.java old mode 100644 new mode 100755 diff --git a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java old mode 100644 new mode 100755 index 247c3037..7cf0be64 --- a/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java +++ b/server/src/main/java/com/google/fhir/gateway/BearerAuthorizationInterceptor.java @@ -231,6 +231,11 @@ private AccessDecision checkAuthorization(RequestDetails requestDetails) { return unauthenticatedQueriesDecision; } // Check the Bearer token to be a valid JWT with required claims. + + AccessDecision allowedQueriesDecision = allowedQueriesChecker.checkAccess(requestDetailsReader); + if (allowedQueriesDecision.canAccess()) { + return allowedQueriesDecision; + } String authHeader = requestDetails.getHeader("Authorization"); if (authHeader == null) { ExceptionUtil.throwRuntimeExceptionAndLog( @@ -238,10 +243,6 @@ private AccessDecision checkAuthorization(RequestDetails requestDetails) { } DecodedJWT decodedJwt = decodeAndVerifyBearerToken(authHeader); FhirContext fhirContext = server.getFhirContext(); - AccessDecision allowedQueriesDecision = allowedQueriesChecker.checkAccess(requestDetailsReader); - if (allowedQueriesDecision.canAccess()) { - return allowedQueriesDecision; - } PatientFinderImp patientFinder = PatientFinderImp.getInstance(fhirContext); AccessChecker accessChecker = accessFactory.create(decodedJwt, fhirClient, fhirContext, patientFinder); @@ -282,13 +283,13 @@ public boolean authorizeRequest(RequestDetails requestDetails) { HttpResponse response = fhirClient.handleRequest(servletDetails); HttpUtil.validateResponseEntityExistsOrFail(response, requestPath); // TODO communicate post-processing failures to the client; see: - // https://github.com/google/fhir-access-proxy/issues/66 + // https://github.com/google/fhir-gateway/issues/66 String content = null; if (HttpUtil.isResponseValid(response)) { try { // For post-processing rationale/example see b/207589782#comment3. - content = outcome.postProcess(response); + content = outcome.postProcess(new RequestDetailsToReader(requestDetails), response); } catch (Exception e) { // Note this is after a successful fetch/update of the FHIR store. That success must be // passed to the client even if the access related post-processing fails. diff --git a/server/src/main/java/com/google/fhir/gateway/BundleResources.java b/server/src/main/java/com/google/fhir/gateway/BundleResources.java new file mode 100755 index 00000000..9a68c3d5 --- /dev/null +++ b/server/src/main/java/com/google/fhir/gateway/BundleResources.java @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway; + +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import lombok.Getter; +import lombok.Setter; +import org.hl7.fhir.instance.model.api.IBaseResource; + +@Getter +@Setter +public class BundleResources { + private RequestTypeEnum requestType; + private IBaseResource resource; + + public BundleResources(RequestTypeEnum requestType, IBaseResource resource) { + this.requestType = requestType; + this.resource = resource; + } +} diff --git a/server/src/main/java/com/google/fhir/gateway/CapabilityPostProcessor.java b/server/src/main/java/com/google/fhir/gateway/CapabilityPostProcessor.java index 399fffe0..0f27b89d 100644 --- a/server/src/main/java/com/google/fhir/gateway/CapabilityPostProcessor.java +++ b/server/src/main/java/com/google/fhir/gateway/CapabilityPostProcessor.java @@ -65,7 +65,8 @@ public boolean canAccess() { } @Override - public String postProcess(HttpResponse response) throws IOException { + public String postProcess(RequestDetailsReader requestDetails, HttpResponse response) + throws IOException { Preconditions.checkState(HttpUtil.isResponseValid(response)); String content = CharStreams.toString(HttpUtil.readerFromEntity(response.getEntity())); IParser parser = fhirContext.newJsonParser(); diff --git a/server/src/main/java/com/google/fhir/gateway/ExceptionUtil.java b/server/src/main/java/com/google/fhir/gateway/ExceptionUtil.java index 90778d97..5edb826e 100644 --- a/server/src/main/java/com/google/fhir/gateway/ExceptionUtil.java +++ b/server/src/main/java/com/google/fhir/gateway/ExceptionUtil.java @@ -53,7 +53,7 @@ static void throwRuntimeExceptionAndLog(Logger logger, String errorMessage) { throwRuntimeExceptionAndLog(logger, errorMessage, null, RuntimeException.class); } - static void throwRuntimeExceptionAndLog(Logger logger, String errorMessage, Exception e) { + public static void throwRuntimeExceptionAndLog(Logger logger, String errorMessage, Exception e) { throwRuntimeExceptionAndLog(logger, errorMessage, e, RuntimeException.class); } } diff --git a/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java b/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java index ec180b63..034879ee 100644 --- a/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java +++ b/server/src/main/java/com/google/fhir/gateway/FhirProxyServer.java @@ -17,6 +17,7 @@ import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.server.ApacheProxyAddressStrategy; import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.fhir.rest.server.interceptor.CorsInterceptor; import com.google.fhir.gateway.GenericFhirClient.GenericFhirClientBuilder; @@ -52,7 +53,7 @@ public class FhirProxyServer extends RestfulServer { // Spring's automatic scanning. @Autowired private Map accessCheckerFactories; - static boolean isDevMode() { + public static boolean isDevMode() { String runMode = System.getenv("RUN_MODE"); return "DEV".equals(runMode); } @@ -108,6 +109,8 @@ protected void initialize() throws ServletException { } catch (IOException e) { ExceptionUtil.throwRuntimeExceptionAndLog(logger, "IOException while initializing", e); } + + setServerAddressStrategy(new ApacheProxyAddressStrategy(true)); } private HttpFhirClient chooseHttpFhirClient(String backendType, String fhirStore) diff --git a/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java b/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java index 0570bdf8..dbf6ebc5 100644 --- a/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java +++ b/server/src/main/java/com/google/fhir/gateway/HttpFhirClient.java @@ -64,7 +64,7 @@ public abstract class HttpFhirClient { // https://www.hl7.org/fhir/async.html // We should NOT copy Content-Length as this is automatically set by the RequestBuilder when // setting content Entity; otherwise we will get a ClientProtocolException. - // TODO(https://github.com/google/fhir-access-proxy/issues/60): Allow Accept header + // TODO(https://github.com/google/fhir-gateway/issues/60): Allow Accept header static final Set REQUEST_HEADERS_TO_KEEP = Sets.newHashSet( "content-type", diff --git a/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java b/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java index 64423844..13f297a7 100644 --- a/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java +++ b/server/src/main/java/com/google/fhir/gateway/PatientFinderImp.java @@ -245,7 +245,7 @@ private Set parseReferencesForPatientIds(IBaseResource resource) { public BundlePatients findPatientsInBundle(Bundle bundle) { if (bundle.getType() != BundleType.TRANSACTION) { // Currently, support only for transaction bundles; see: - // https://github.com/google/fhir-access-proxy/issues/67 + // https://github.com/google/fhir-gateway/issues/67 ExceptionUtil.throwRuntimeExceptionAndLog( logger, "Bundle type needs to be transaction!", InvalidRequestException.class); } diff --git a/server/src/main/java/com/google/fhir/gateway/ProxyConstants.java b/server/src/main/java/com/google/fhir/gateway/ProxyConstants.java index edb50b64..2458161a 100644 --- a/server/src/main/java/com/google/fhir/gateway/ProxyConstants.java +++ b/server/src/main/java/com/google/fhir/gateway/ProxyConstants.java @@ -20,6 +20,26 @@ public class ProxyConstants { + public static final String CARE_TEAM_TAG_URL = "https://smartregister.org/care-team-tag-id"; + + public static final String LOCATION_TAG_URL = "https://smartregister.org/location-tag-id"; + + public static final String ORGANISATION_TAG_URL = "https://smartregister.org/organisation-tag-id"; + + public static final String TAG_SEARCH_PARAM = "_tag"; + + public static final String PARAM_VALUES_SEPARATOR = ","; + + public static final String CODE_URL_VALUE_SEPARATOR = "|"; + + public static final String HTTP_URL_SEPARATOR = "/"; + // Note we should not set charset here; otherwise GCP FHIR store complains about Content-Type. static final ContentType JSON_PATCH_CONTENT = ContentType.create(Constants.CT_JSON_PATCH); + public static final String SYNC_STRATEGY = "syncStrategy"; + public static final String REALM_ACCESS = "realm_access"; + + public interface Literals { + String EQUALS = "="; + } } diff --git a/server/src/main/java/com/google/fhir/gateway/ResourceFinderImp.java b/server/src/main/java/com/google/fhir/gateway/ResourceFinderImp.java new file mode 100755 index 00000000..c43cbb0c --- /dev/null +++ b/server/src/main/java/com/google/fhir/gateway/ResourceFinderImp.java @@ -0,0 +1,98 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.parser.IParser; +import ca.uhn.fhir.rest.api.RequestTypeEnum; +import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; +import com.google.fhir.gateway.interfaces.RequestDetailsReader; +import com.google.fhir.gateway.interfaces.ResourceFinder; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.Bundle; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class ResourceFinderImp implements ResourceFinder { + private static final Logger logger = LoggerFactory.getLogger(ResourceFinderImp.class); + private static ResourceFinderImp instance = null; + private final FhirContext fhirContext; + + // This is supposed to be instantiated with getInstance method only. + private ResourceFinderImp(FhirContext fhirContext) { + this.fhirContext = fhirContext; + } + + private IBaseResource createResourceFromRequest(RequestDetailsReader request) { + byte[] requestContentBytes = request.loadRequestContents(); + Charset charset = request.getCharset(); + if (charset == null) { + charset = StandardCharsets.UTF_8; + } + String requestContent = new String(requestContentBytes, charset); + IParser jsonParser = fhirContext.newJsonParser(); + return jsonParser.parseResource(requestContent); + } + + @Override + public List findResourcesInBundle(RequestDetailsReader request) { + IBaseResource resource = createResourceFromRequest(request); + if (!(resource instanceof Bundle)) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, "The provided resource is not a Bundle!", InvalidRequestException.class); + } + Bundle bundle = (Bundle) resource; + + if (bundle.getType() != Bundle.BundleType.TRANSACTION) { + // Currently, support only for transaction bundles + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, "Bundle type needs to be transaction!", InvalidRequestException.class); + } + + List requestTypeEnumList = new ArrayList<>(); + if (!bundle.hasEntry()) { + return requestTypeEnumList; + } + + for (Bundle.BundleEntryComponent entryComponent : bundle.getEntry()) { + Bundle.HTTPVerb httpMethod = entryComponent.getRequest().getMethod(); + if (httpMethod != Bundle.HTTPVerb.GET && !entryComponent.hasResource()) { + ExceptionUtil.throwRuntimeExceptionAndLog( + logger, "Bundle entry requires a resource field!", InvalidRequestException.class); + } + + requestTypeEnumList.add( + new BundleResources( + RequestTypeEnum.valueOf(httpMethod.name()), entryComponent.getResource())); + } + + return requestTypeEnumList; + } + + // A singleton instance of this class should be used, hence the constructor is private. + public static synchronized ResourceFinderImp getInstance(FhirContext fhirContext) { + if (instance != null) { + return instance; + } + + instance = new ResourceFinderImp(fhirContext); + return instance; + } +} diff --git a/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java b/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java index edaf5ccc..6559e427 100644 --- a/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java +++ b/server/src/main/java/com/google/fhir/gateway/interfaces/AccessDecision.java @@ -53,5 +53,6 @@ public interface AccessDecision { * reads the response; otherwise null. Note that we should try to avoid reading the whole * content in memory whenever it is not needed for post-processing. */ - String postProcess(HttpResponse response) throws IOException; + String postProcess(RequestDetailsReader requestDetailsReader, HttpResponse response) + throws IOException; } diff --git a/server/src/main/java/com/google/fhir/gateway/interfaces/NoOpAccessDecision.java b/server/src/main/java/com/google/fhir/gateway/interfaces/NoOpAccessDecision.java index d0394811..7921c983 100644 --- a/server/src/main/java/com/google/fhir/gateway/interfaces/NoOpAccessDecision.java +++ b/server/src/main/java/com/google/fhir/gateway/interfaces/NoOpAccessDecision.java @@ -36,7 +36,7 @@ public boolean canAccess() { } @Override - public String postProcess(HttpResponse response) { + public String postProcess(RequestDetailsReader requestDetailsReader, HttpResponse response) { return null; } diff --git a/server/src/main/java/com/google/fhir/gateway/interfaces/ResourceFinder.java b/server/src/main/java/com/google/fhir/gateway/interfaces/ResourceFinder.java new file mode 100755 index 00000000..7ea67781 --- /dev/null +++ b/server/src/main/java/com/google/fhir/gateway/interfaces/ResourceFinder.java @@ -0,0 +1,24 @@ +/* + * Copyright 2021-2023 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.fhir.gateway.interfaces; + +import com.google.fhir.gateway.BundleResources; +import java.util.List; + +public interface ResourceFinder { + + List findResourcesInBundle(RequestDetailsReader request); +} diff --git a/server/src/test/java/com/google/fhir/gateway/AllowedQueriesCheckerTest.java b/server/src/test/java/com/google/fhir/gateway/AllowedQueriesCheckerTest.java old mode 100644 new mode 100755 index d10035b7..088ad14d --- a/server/src/test/java/com/google/fhir/gateway/AllowedQueriesCheckerTest.java +++ b/server/src/test/java/com/google/fhir/gateway/AllowedQueriesCheckerTest.java @@ -200,4 +200,61 @@ public void denyRequestTypeMisMatch() throws IOException { assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); } + + @Test + public void validGetCompositionQuery() throws IOException { + // Query: GET /Composition + when(requestMock.getRequestPath()).thenReturn("/Composition"); + URL configFileUrl = Resources.getResource("hapi_page_url_allowed_queries.json"); + AllowedQueriesChecker testInstance = new AllowedQueriesChecker(configFileUrl.getPath()); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + + @Test + public void validGetListQueryWithSpecificPathVariableValue() throws IOException { + // Query: PUT /List/some-value-x-anything + when(requestMock.getRequestPath()).thenReturn("/List/some-value-x-anything"); + URL configFileUrl = Resources.getResource("hapi_page_url_allowed_queries.json"); + AllowedQueriesChecker testInstance = new AllowedQueriesChecker(configFileUrl.getPath()); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + + @Test + public void validGetBinaryQueryWithExpectedPathVariable() throws IOException { + // Query: GET /Binary/1234567 + when(requestMock.getRequestPath()).thenReturn("/Binary/1234567"); + URL configFileUrl = Resources.getResource("hapi_page_url_allowed_queries.json"); + AllowedQueriesChecker testInstance = new AllowedQueriesChecker(configFileUrl.getPath()); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + + @Test + public void denyGetBinaryQueryWithUnexpectedPathVariable() throws IOException { + // Query: GET /Binary/unauthorized-path-variable + when(requestMock.getRequestPath()).thenReturn("/Binary/unauthorized-path-variable"); + URL configFileUrl = Resources.getResource("hapi_page_url_allowed_queries.json"); + AllowedQueriesChecker testInstance = new AllowedQueriesChecker(configFileUrl.getPath()); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); + } + + @Test + public void validGetPatientQueryWithExpectedGetParamsAndPathVariable() throws IOException { + // Query: GET /Patient/8899900 + when(requestMock.getRequestPath()).thenReturn("/Patient/8899900"); + Map params = Maps.newHashMap(); + params.put("_sort", new String[] {"name"}); + when(requestMock.getParameters()).thenReturn(params); + URL configFileUrl = Resources.getResource("hapi_page_url_allowed_queries.json"); + AllowedQueriesChecker testInstance = new AllowedQueriesChecker(configFileUrl.getPath()); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(true)); + } + + @Test + public void denyGetPatientQueryWithEmptyPathVariable() throws IOException { + // Query: GET /Patient/ + when(requestMock.getRequestPath()).thenReturn("/Patient/"); + URL configFileUrl = Resources.getResource("hapi_page_url_allowed_queries.json"); + AllowedQueriesChecker testInstance = new AllowedQueriesChecker(configFileUrl.getPath()); + assertThat(testInstance.checkAccess(requestMock).canAccess(), equalTo(false)); + } } diff --git a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java index 5e8d09f9..f6a4ea70 100644 --- a/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java +++ b/server/src/test/java/com/google/fhir/gateway/BearerAuthorizationInterceptorTest.java @@ -375,11 +375,15 @@ public boolean canAccess() { return true; } + public void preProcess(ServletRequestDetails servletRequestDetails) {} + public RequestMutation getRequestMutation(RequestDetailsReader requestDetailsReader) { return RequestMutation.builder().queryParams(paramMutations).build(); } - public String postProcess(HttpResponse response) throws IOException { + @Override + public String postProcess( + RequestDetailsReader requestDetailsReader, HttpResponse response) throws IOException { return null; } }; diff --git a/server/src/test/resources/allowed_queries_with_no_extra_params.json b/server/src/test/resources/allowed_queries_with_no_extra_params.json index 9fd931fb..5281ad2b 100644 --- a/server/src/test/resources/allowed_queries_with_no_extra_params.json +++ b/server/src/test/resources/allowed_queries_with_no_extra_params.json @@ -2,6 +2,7 @@ "entries": [ { "path": "", + "methodType": "GET", "queryParams": { "_getpages": "ANY_VALUE" }, diff --git a/server/src/test/resources/hapi_page_url_allowed_queries.json b/server/src/test/resources/hapi_page_url_allowed_queries.json index de947004..73702c86 100644 --- a/server/src/test/resources/hapi_page_url_allowed_queries.json +++ b/server/src/test/resources/hapi_page_url_allowed_queries.json @@ -2,11 +2,48 @@ "entries": [ { "path": "", + "methodType": "GET", "queryParams": { "_getpages": "ANY_VALUE" }, "allowExtraParams": true, "allParamsRequired": true + }, + { + "path": "/Composition", + "methodType": "GET", + "queryParams": { + + }, + "allowExtraParams": true, + "allParamsRequired": false + }, + { + "path": "/Binary/1234567", + "methodType": "GET", + "queryParams": { + + }, + "allowExtraParams": true, + "allParamsRequired": false + }, + { + "path": "/List/ANY_VALUE", + "methodType": "PUT", + "queryParams": { + + }, + "allowExtraParams": true, + "allParamsRequired": false + }, + { + "path": "/Patient/ANY_VALUE", + "methodType": "GET", + "queryParams": { + "_sort": "name" + }, + "allowExtraParams": false, + "allParamsRequired": true } ] } \ No newline at end of file