Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[ENH] Add environment variable for allowed origins for CORS #151

Merged
merged 18 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .template-env
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ NB_GRAPH_PASSWORD=DBPASSWORD # REPLACE DBPASSWORD WITH YOUR GRAPH DATABASE PASS
NB_GRAPH_DB=test_data/query
NB_RETURN_AGG=true
NB_API_TAG=latest
NB_API_ALLOWED_ORIGINS="https://localhost:3000 http://localhost:3000" # Example of allowing multiple origins, edit as needed for your own setup
NB_GRAPH_IMG=stardog/stardog:8.2.2-java11-preview
## ADDITIONAL CONFIGURABLE PARAMETERS: Uncomment and modify values of the below variables as needed to use non-default values.
# NB_API_PORT_HOST=8000
Expand Down
64 changes: 48 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The Neurobagel API is a REST API, developed in [Python](https://www.python.org/)
- [Quickstart](#quickstart)
- [Local installation](#local-installation)
- [Environment variables](#set-the-environment-variables)
- [Using a graphical query tool](#using-a-graphical-query-tool-to-send-api-requests)
- [Docker](#docker)
- [Python](#python)
- [Testing](#testing)
Expand All @@ -49,32 +50,35 @@ git clone https://github.com/neurobagel/api.git
### Set the environment variables
Create a file called `.env` in the root of the repository will house the environment variables used by the app.

To run API requests against a graph, at least two environment variables must be set, `NB_GRAPH_USERNAME` and `NB_GRAPH_PASSWORD`.
To run API requests against a graph, at least two environment variables must be set, `NB_GRAPH_USERNAME` and `NB_GRAPH_PASSWORD`.

This repository contains a [template `.env` file](/.template-env) that you can copy and edit.

Below are explanations of all the possible Neurobagel environment variables that you can set in `.env`, depending on your mode of installation of the API and graph server software.
| Environment variable | Required in .env? | Description | Default value if not set | Relevant installation mode(s) |
| -------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------------------------- |
| `NB_GRAPH_USERNAME` | Yes | Username to access Stardog graph database that API will communicate with | - | Docker, Python |
| `NB_GRAPH_PASSWORD` | Yes | Password to access Stardog graph database that API will communicate with | - | Docker, Python |
| `NB_GRAPH_ADDRESS` | No | IP address for the graph database (or container name, if graph is hosted locally) | `206.12.99.17` (`graph`) ** | Docker, Python |
| `NB_GRAPH_DB` | No | Name of graph database endpoint to query (e.g., for a Stardog database, this will take the format of `{database_name}/query`) | `test_data/query` | Docker, Python |
| `NB_RETURN_AGG` | No | Whether to return only dataset-level query results (including data locations) and exclude subject-level attributes. One of [true, false] | `true` | Docker, Python |
| `NB_API_TAG` | No | Tag for API Docker image | `latest` | Docker |
| `NB_API_PORT_HOST` | No | Port number on the _host machine_ to map the API container port to | `8000` | Docker |
| `NB_API_PORT` | No | Port number on which to run the API | `8000` | Docker, Python |
| `NB_GRAPH_IMG` | No | Graph server Docker image | `stardog/stardog:8.2.2-java11-preview` | Docker |
| `NB_GRAPH_ROOT_HOST` | No | Path to directory containing a Stardog license file on the _host machine_ | `~/stardog-home` | Docker |
| `NB_GRAPH_ROOT_CONT` | No | Path to directory for graph databases in the _graph server container_ | `/var/opt/stardog` * | Docker |
| `NB_GRAPH_PORT_HOST` | No | Port number on the _host machine_ to map the graph server container port to | `5820` | Docker |
| `NB_GRAPH_PORT` | No | Port number used by the _graph server container_ | `5820` * | Docker, Python |
| Environment variable | Required in .env? | Description | Default value if not set | Relevant installation mode(s) |
| ------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------------------------- |
| `NB_GRAPH_USERNAME` | Yes | Username to access Stardog graph database that API will communicate with | - | Docker, Python |
| `NB_GRAPH_PASSWORD` | Yes | Password to access Stardog graph database that API will communicate with | - | Docker, Python |
| `NB_GRAPH_ADDRESS` | No | IP address for the graph database (or container name, if graph is hosted locally) | `206.12.99.17` (`graph`) ** | Docker, Python |
| `NB_GRAPH_DB` | No | Name of graph database endpoint to query (e.g., for a Stardog database, this will take the format of `{database_name}/query`) | `test_data/query` | Docker, Python |
| `NB_RETURN_AGG` | No | Whether to return only dataset-level query results (including data locations) and exclude subject-level attributes. One of [true, false] | `true` | Docker, Python |
| `NB_API_TAG` | No | Tag for API Docker image | `latest` | Docker |
| `NB_API_PORT_HOST` | No | Port number on the _host machine_ to map the API container port to | `8000` | Docker |
| `NB_API_PORT` | No | Port number on which to run the API | `8000` | Docker, Python |
| `NB_API_ALLOWED_ORIGINS` | No † | Origins allowed to make [cross-origin resource sharing](https://fastapi.tiangolo.com/tutorial/cors/) requests. Multiple origins must be separated with spaces in a single string enclosed in quotes. _NOTE: To make the API accessible from a frontend query tool, the origin must be explicitly set. See_ † _for more info_ | `""` | Docker, Python |
| `NB_GRAPH_IMG` | No | Graph server Docker image | `stardog/stardog:8.2.2-java11-preview` | Docker |
| `NB_GRAPH_ROOT_HOST` | No | Path to directory containing a Stardog license file on the _host machine_ | `~/stardog-home` | Docker |
| `NB_GRAPH_ROOT_CONT` | No | Path to directory for graph databases in the _graph server container_ | `/var/opt/stardog` * | Docker |
| `NB_GRAPH_PORT_HOST` | No | Port number on the _host machine_ to map the graph server container port to | `5820` | Docker |
| `NB_GRAPH_PORT` | No | Port number used by the _graph server container_ | `5820` * | Docker, Python |

_* These defaults are configured for a Stardog backend - you should not have to change them if you are running a Stardog backend._

_** If using the [docker compose installation route](#option-1-recommended-use-the-docker-composeyaml-file),
do not change `NB_API_ADDRESS` from its default value (`graph`) as this corresponds to the preset container name of the graph database server within the docker compose network._

_† See section [Using a graphical query tool to send API requests](#using-a-graphical-query-tool-to-send-api-requests)_

---
**IMPORTANT:**
- Variables set in the shell environment where the API is launched **_should not be used as a replacement for the `.env` file_** to configure options for the API or graph server software.
Expand All @@ -84,6 +88,34 @@ also ensure that any variables defined in your `.env` are not already set in you

The below instructions for Docker and Python assume that you have at least set `NB_GRAPH_USERNAME` and `NB_GRAPH_PASSWORD` in your `.env`.

### Using a graphical query tool to send API requests
The `NB_API_ALLOWED_ORIGINS` variable defaults to an empty string (`""`) when unset, meaning that your deployed API will only accessible via direct `curl` requests to the URL where the API is hosted (see [this section](#send-a-test-query-to-the-api) for an example `curl` request).

However, in many cases you may want to make the API accessible by a frontend tool such as our [browser query tool](https://github.com/neurobagel/query-tool).
To do so, you must explicitly specify the origin(s) for the frontend using `NB_API_ALLOWED_ORIGINS` in `.env`.

For example, the `.template-env` file in this repo assumes you want to allow API requests from a query tool hosted at a specific port on `localhost`.

Other examples:
```bash
# ---- .env file ----

# do not allow requests from any frontend origins
NB_API_ALLOWED_ORIGINS="" # this is the default value that will also be set if the variable is excluded from the .env file

# allow requests from only one origin
NB_API_ALLOWED_ORIGINS="https://query.neurobagel.org"

# allow requests from 3 different origins
NB_API_ALLOWED_ORIGINS="https://query.neurobagel.org https://localhost:3000 http://localhost:3000"

# allow requests from any origin - use with caution
NB_API_ALLOWED_ORIGINS="*"
```

**A note for more technical users:** If you have configured an NGINX reverse proxy (or proxy requests to the remote origin) to serve both the API and the query tool from the same origin, you can skip the step of enabling CORS for the API.
For an example, see https://stackoverflow.com/a/28599481.

### Docker
First, [install docker](https://docs.docker.com/get-docker/).

Expand Down
11 changes: 11 additions & 0 deletions app/api/utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

# Request constants
EnvVar = namedtuple("EnvVar", ["name", "val"])

ALLOWED_ORIGINS = EnvVar(
"NB_API_ALLOWED_ORIGINS", os.environ.get("NB_API_ALLOWED_ORIGINS", "")
)

GRAPH_USERNAME = EnvVar(
"NB_GRAPH_USERNAME", os.environ.get("NB_GRAPH_USERNAME")
)
Expand Down Expand Up @@ -58,6 +63,12 @@
IS_CONTROL_TERM = "purl:NCIT_C94342" # TODO: Remove once https://github.com/neurobagel/bagel-cli/issues/139 is resolved.


def parse_origins_as_list(allowed_origins: str) -> list:
"""Returns user-defined allowed origins as a list."""
print(list(allowed_origins.split(" ")))
alyssadai marked this conversation as resolved.
Show resolved Hide resolved
return list(allowed_origins.split(" "))
alyssadai marked this conversation as resolved.
Show resolved Hide resolved


def create_query(
return_agg: bool,
age: Optional[tuple] = (None, None),
Expand Down
16 changes: 15 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Main app."""

import os
import warnings

import uvicorn
from fastapi import FastAPI
Expand All @@ -14,7 +15,7 @@

app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=util.parse_origins_as_list(util.ALLOWED_ORIGINS.val),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand All @@ -25,6 +26,7 @@
async def auth_check():
"""Checks whether username and password environment variables are set."""
if (
# TODO: Check if this error is still raised when variables are empty strings
os.environ.get(util.GRAPH_USERNAME.name) is None
or os.environ.get(util.GRAPH_PASSWORD.name) is None
):
Expand All @@ -33,6 +35,18 @@ async def auth_check():
)


@app.on_event("startup")
alyssadai marked this conversation as resolved.
Show resolved Hide resolved
async def allowed_origins_check():
"""Raises warning if allowed origins environment variable has not been set or is an empty string."""
if os.environ.get(util.ALLOWED_ORIGINS.name, "") == "":
warnings.warn(
f"The API was launched without providing any values for the {util.ALLOWED_ORIGINS.name} environment variable. "
"This means that the API will only be accessible from the same origin it is hosted from: https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy. "
f"If you want to access the API from tools hosted at other origins such as the Neurobagel query tool, explicitly set the value of {util.ALLOWED_ORIGINS.name} to the origin(s) of these tools (e.g. http://localhost:3000). "
"Multiple allowed origins should be separated with spaces in a single string enclosed in quotes. "
)


app.include_router(query.router)

# Automatically start uvicorn server on execution of main.py
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ services:
NB_GRAPH_DB: ${NB_GRAPH_DB:-test_data/query}
NB_RETURN_AGG: ${NB_RETURN_AGG:-true}
NB_API_PORT: ${NB_API_PORT:-8000}
NB_API_ALLOWED_ORIGINS: ${NB_API_ALLOWED_ORIGINS}
graph:
image: "${NB_GRAPH_IMG:-stardog/stardog:8.2.2-java11-preview}"
volumes:
Expand Down
73 changes: 73 additions & 0 deletions tests/test_query.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Test API to query subjects from the Stardog graph who match user-specified criteria."""

import os
import warnings

import httpx
import pytest
from fastapi import HTTPException
Expand Down Expand Up @@ -114,6 +117,76 @@ def mock_httpx_post(**kwargs):
assert response.status_code == 401


def test_app_with_unset_allowed_origins(test_app, monkeypatch):
"""Tests that when the environment variable for allowed origins has not been set, a warning is raised and the app uses a default value."""
monkeypatch.delenv(util.ALLOWED_ORIGINS.name, raising=False)
# set random username and password to avoid RuntimeError from other startup check
monkeypatch.setenv(util.GRAPH_USERNAME.name, "DBUSER")
monkeypatch.setenv(util.GRAPH_PASSWORD.name, "DBPASSWORD")

with pytest.warns(
UserWarning,
match=f"API was launched without providing any values for the {util.ALLOWED_ORIGINS.name} environment variable",
):
with test_app:
pass

assert util.parse_origins_as_list(
surchs marked this conversation as resolved.
Show resolved Hide resolved
os.environ.get(util.ALLOWED_ORIGINS.name, "")
) == [""]


@pytest.mark.parametrize(
"allowed_origins, parsed_origins, expectation",
[
(
"",
[""],
pytest.warns(
UserWarning,
match=f"API was launched without providing any values for the {util.ALLOWED_ORIGINS.name} environment variable",
),
),
(
"http://localhost:3000",
["http://localhost:3000"],
warnings.catch_warnings(),
),
(
"http://localhost:3000 https://localhost:3000",
["http://localhost:3000", "https://localhost:3000"],
warnings.catch_warnings(),
),
alyssadai marked this conversation as resolved.
Show resolved Hide resolved
(
" http://localhost:3000 https://localhost:3000 ",
["http://localhost:3000", "https://localhost:3000"],
warnings.catch_warnings(),
),
],
)
def test_app_with_set_allowed_origins(
test_app, monkeypatch, allowed_origins, parsed_origins, expectation
):
"""
Test that when the environment variable for allowed origins has been explicitly set, the app correctly parses it into a list
and raises a warning if the value is an empty string.
"""
monkeypatch.setenv(util.ALLOWED_ORIGINS.name, allowed_origins)
# set random username and password to avoid RuntimeError from other startup check
monkeypatch.setenv(util.GRAPH_USERNAME.name, "DBUSER")
monkeypatch.setenv(util.GRAPH_PASSWORD.name, "DBPASSWORD")

with expectation:
with test_app:
surchs marked this conversation as resolved.
Show resolved Hide resolved
pass

assert set(parsed_origins).issubset(
util.parse_origins_as_list(
os.environ.get(util.ALLOWED_ORIGINS.name, "")
)
)


def test_get_all(test_app, mock_successful_get, monkeypatch):
"""Given no input for the sex parameter, returns a 200 status code and a non-empty list of results (should correspond to all subjects in graph)."""

Expand Down