diff --git a/VERSION b/VERSION
index f90b1af..0bee604 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.3.2
+2.3.3
diff --git a/api_endpoints.localhost.json b/api_endpoints.localhost.json
new file mode 100644
index 0000000..b9a33cf
--- /dev/null
+++ b/api_endpoints.localhost.json
@@ -0,0 +1,870 @@
+{
+ "_comment_services": "Service names below are Docker hostnames on the gateway_hubmap network. List with: docker network inspect gateway_hubmap | grep Name",
+ "_comment_url_ingest-api": "http://localhost:12123",
+ "_comment_url_uuid-api": "http://localhost:2222",
+ "_comment_url_entity-api": "http://localhost:3333",
+ "_comment_url_search-api": "http://localhost:4444",
+ "entity-api": [
+ {
+ "method": "GET",
+ "endpoint": "/"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/status"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/usergroups",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entity-types"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/entities/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/entities/<*>",
+ "authorizer": "create"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/provenance"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/revisions"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/tuplets"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/collections"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/uploads"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/globus-url"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/ancestor-organs"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/siblings"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/instanceof/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/type/<*>/instanceof/<*>"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/entities/batch-ids"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/entities/multiple-samples/<*>",
+ "authorizer": "create"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/ancestors/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/descendants/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/parents/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/children/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/unpublished",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/sankey_data"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/prov-info"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/prov-metadata"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/revisions"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/revision"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/latest-revision"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/donors"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/samples"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/organs"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/paired-dataset"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/<*>/retract",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/components",
+ "authorizer": "create"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/dataset/globus-url/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/dataset/globus-url/<*>"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/uploads"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/previous_revisions/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/next_revisions/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/redirect/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/doi/redirect/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/collection/redirect/<*>"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/constraints"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/documents/<*>"
+ },
+ {
+ "method": "DELETE",
+ "endpoint": "/flush-cache/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "DELETE",
+ "endpoint": "/flush-all-cache",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "DELETE",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ }
+ ],
+ "uuid-api": [
+ {
+ "method": "GET",
+ "endpoint": "/"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/status"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/uuid"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/uuid/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/uuid/<*>/exists",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/hmuuid/<*>"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/hmuuid",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/hmuuid/<*>/exists",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/file-id/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/<*>/files",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/<*>/ancestors"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "DELETE",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ }
+ ],
+ "search-api": [
+ {
+ "method": "GET",
+ "endpoint": "/"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/status"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/search"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/search"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/indices"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/mapping"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/mget"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/attribute-values"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/reindex-status"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/reindex-status/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/param-search/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/<*>/search"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/<*>/search"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/<*>/mapping"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/<*>/attribute-values"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/<*>/mget"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/<*>/scroll-search"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/reindex-all",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/reindex/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/update/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/update/<*>/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/update/<*>/<*>/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/add/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/add/<*>/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/add/<*>/<*>/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/clear-docs/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/clear-docs/<*>/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/clear-docs/<*>/<*>/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ },
+ {
+ "method": "DELETE",
+ "endpoint": "/<*>",
+ "_comment": "Catch-all - allows Flask to return proper 404 for undefined routes"
+ }
+ ],
+ "ingest-api": [
+ {
+ "method": "GET",
+ "endpoint": "/"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/favicon.ico"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/status"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/notify",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/publications",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/verifytitleinfo",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/derived"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/<*>/status/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/<*>/file-system-abs-path"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/file-system-abs-path"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/uploads/file-system-abs-path"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/entities/file-system-rel-path"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/entities/accessible-data-directories"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/uploads/<*>/file-system-abs-path"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/dataset/begin-extract-cell-count-from-secondary-analysis-files-async"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/ingest",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/submissions/request_ingest"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/<*>/validate",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/<*>/publish",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/collections/<*>/register-doi",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/bulk/submit",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/<*>/metadata-json",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/<*>/submit",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/<*>/unpublish",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/datasets/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/new-collection",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/login"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/logout"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/file-upload",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/file-commit",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/file-remove",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/uploads",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/uploads/<*>/validate",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/uploads/<*>/reorganize",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/uploads/<*>/submit",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/uploads/<*>/arrange-into-datasets",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/data-provider-groups",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/usergroups"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/userroles",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/usercanedit/type",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/usercanedit/type/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/usercanedit/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/groups/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/source/type/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/source/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/metadata/<*>",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/metadata/validate",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/specimens/<*>/ingest-group-ids",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/entities/<*>/allowable-edit-states",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/donors/bulk-upload",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/donors/bulk",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/samples/bulk-upload",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/samples/bulk",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/sample-bulk-metadata",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/dataset/begin-extract-cell-count-from-secondary-analysis-files-async",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/dataset/extract-cell-count-from-secondary-analysis-files",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/datasets/data-status"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/uploads/data-status"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/data-ingest-board-login"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/data-ingest-board-logout"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/umls-auth"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/ubkg-download-file-list"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/components",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/assaytype"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/assaytype/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/assaytype/metadata/<*>"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/reload-assaytypes",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/privs/has-data-admin"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/has-pipeline-test-privs"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/<*>/submit-for-pipeline-testing",
+ "authorizer": "pipeline-test"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/submit-for-pipeline-testing",
+ "authorizer": "pipeline-test"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/datasets/validate",
+ "authorizer": "data-admin"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/uploads/validate",
+ "authorizer": "data-admin"
+ }
+ ],
+ "antibody-api": [
+ {
+ "method": "GET",
+ "endpoint": "/"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/static/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/static/dist/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/css/<*>"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/login"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/logout"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/antibodies"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/antibodies",
+ "authorizer": "read"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/antibodies/import",
+ "authorizer": "read"
+ },
+ {
+ "method": "PUT",
+ "endpoint": "/restore_elasticsearch",
+ "authorizer": "read"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/upload"
+ },
+ {
+ "method": "POST",
+ "endpoint": "/_search"
+ },
+ {
+ "method": "GET",
+ "endpoint": "/status"
+ }
+ ]
+}
diff --git a/docker-compose.development.yml b/docker-compose.development.yml
index f91261a..b6b0652 100644
--- a/docker-compose.development.yml
+++ b/docker-compose.development.yml
@@ -23,7 +23,7 @@ services:
- "./BUILD:/usr/src/app/BUILD"
# Mount the source code to container
- "./hubmap-auth/src/:/usr/src/app/src"
- # Mount conf.d-test to the nginx conf.d on container
+ # Mount conf.d-dev to the nginx conf.d on container
- "./nginx/conf.d-dev:/etc/nginx/conf.d"
# Mount ssl certificates from host to container
- "/etc/letsencrypt:/etc/letsencrypt"
diff --git a/docker-compose.localhost.yml b/docker-compose.localhost.yml
new file mode 100644
index 0000000..56f4573
--- /dev/null
+++ b/docker-compose.localhost.yml
@@ -0,0 +1,46 @@
+services:
+
+ hubmap-auth:
+ build:
+ context: ./hubmap-auth
+ # Uncomment if tesitng against a specific branch of commons other than the PyPI package
+ # Will also need to use the 'git+https://github.com/hubmapconsortium/commons.git@${COMMONS_BRANCH}#egg=hubmap-commons'
+ # in src/requirements.txt accordingly
+ args:
+ # The commons github branch to be used during image build (default to main if not set or null)
+ - COMMONS_BRANCH=${COMMONS_BRANCH:-main}
+ # Build the image with name and tag
+ # Exit with an error message containing err if unset or empty in the environment
+ image: hubmap/hubmap-auth:${HUBMAP_AUTH_VERSION:?err}
+ environment:
+ - DEPLOY_MODE=localhost
+ ports:
+ # Proxy pass to a REST API on localhost, which will be
+ # port used by an API running on a PSC VM for higher tiers e.g.
+ # 7777 for Workspaces API on vm001.hive.psc.edu.
+ # Entity API on localhost Docker Container listening on port 3333
+ # mapped to
+ - "7777:7777"
+ volumes:
+ # Mount the VERSION file and BUILD file
+ - "./VERSION:/usr/src/app/VERSION"
+ - "./BUILD:/usr/src/app/BUILD"
+ # Mount the source code to container
+ - "./hubmap-auth/src/:/usr/src/app/src"
+ # Mount conf.d-localhost to the nginx conf.d on container
+ - "./nginx/conf.d-localhost:/etc/nginx/conf.d"
+ # No SSL certificates mounted in container during
+ # localhost development, unlike containers on DEV or
+ # other servers
+ # - "/etc/letsencrypt:/etc/letsencrypt"
+ # Mount the API endpoints json file for API endpoints lookup
+ - "./api_endpoints.localhost.json:/usr/src/app/api_endpoints.json"
+ healthcheck:
+ # Replaces base healthcheck - checks localhost:7777 for entity-api instead of localhost:8080 for /
+ test: ["CMD", "curl", "--fail", "http://localhost:7777/status.json"]
+ interval: 12h # Set long on localhost to avoid logging that interferes with development
+ timeout: 5s
+ retries: 5
+ start_period: 30s
+ networks:
+ - gateway_hubmap # Docker network defined in docker-compose.yml
diff --git a/docker-compose.yml b/docker-compose.yml
index 763e7db..40a228b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,6 +34,6 @@ services:
- "./nginx/html:/usr/share/nginx/html"
networks:
- # This is the network created by gateway to enable communicaton between multiple docker-compose projects
+ # This is the network created by gateway to enable communication between multiple docker-compose projects
gateway_hubmap:
external: true
diff --git a/docker-development.sh b/docker-development.sh
index b52b08b..8273099 100755
--- a/docker-development.sh
+++ b/docker-development.sh
@@ -22,7 +22,7 @@ function generate_build_version() {
truncate -s 0 BUILD
# Note: echo to file appends newline
echo $GIT_BRANCH_NAME:$GIT_SHORT_COMMIT_HASH >> BUILD
- # Remmove the trailing newline character
+ # Remove the trailing newline character
truncate -s -1 BUILD
echo "BUILD(git branch name:short commit hash): $GIT_BRANCH_NAME:$GIT_SHORT_COMMIT_HASH"
diff --git a/docker-localhost.sh b/docker-localhost.sh
new file mode 100755
index 0000000..79e6707
--- /dev/null
+++ b/docker-localhost.sh
@@ -0,0 +1,115 @@
+#!/bin/bash
+
+# Print a new line and the banner
+echo
+echo "==================== HUBMAP-AUTH ===================="
+
+function tier_check() {
+ # Get the script name and extract DEPLOY_TIER
+ SCRIPT_NAME=$(basename "$0")
+
+ # Extract deploy tier from script name (docker-*.sh pattern)
+ if [[ $SCRIPT_NAME =~ docker-(.*)\.sh ]]; then
+ DEPLOY_TIER="${BASH_REMATCH[1]}"
+ else
+ echo "Error: Script name doesn't match pattern 'docker-*.sh'"
+ exit 1
+ fi
+ echo "Executing ${SCRIPT_NAME} to deploy in Docker on ${DEPLOY_TIER}"
+}
+
+# Set the version environment variable for the docker build
+# Version number is from the VERSION file
+# Also remove newlines and leading/trailing slashes if present in that VERSION file
+# Note: the BUILD and VERSION files are in the same dir as this script, this is different from other APIs
+function export_version() {
+ export HUBMAP_AUTH_VERSION=$(tr -d "\n\r" < VERSION | xargs)
+ echo "HUBMAP_AUTH_VERSION: $HUBMAP_AUTH_VERSION"
+}
+
+# Generate the build version based on git branch name and short commit hash and write into BUILD file
+# Note: the BUILD and VERSION files are in the same dir as this script, this is different from other APIs
+function generate_build_version() {
+ GIT_BRANCH_NAME=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p')
+ GIT_SHORT_COMMIT_HASH=$(git rev-parse --short HEAD)
+ # Clear the old BUILD version and write the new one
+ truncate -s 0 BUILD
+ # Note: echo to file appends newline
+ echo $GIT_BRANCH_NAME:$GIT_SHORT_COMMIT_HASH >> BUILD
+ # Remove the trailing newline character
+ truncate -s -1 BUILD
+
+ echo "BUILD(git branch name:short commit hash): $GIT_BRANCH_NAME:$GIT_SHORT_COMMIT_HASH"
+}
+
+# This function sets DIR to the directory in which this script itself is found.
+# Thank you https://stackoverflow.com/questions/59895/how-to-get-the-source-directory-of-a-bash-script-from-within-the-script-itself
+function get_dir_of_this_script () {
+ SCRIPT_SOURCE="${BASH_SOURCE[0]}"
+ while [ -h "$SCRIPT_SOURCE" ]; do # resolve $SCRIPT_SOURCE until the file is no longer a symlink
+ DIR="$( cd -P "$( dirname "$SCRIPT_SOURCE" )" >/dev/null 2>&1 && pwd )"
+ SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")"
+ [[ $SCRIPT_SOURCE != /* ]] && SCRIPT_SOURCE="$DIR/$SCRIPT_SOURCE" # if $SCRIPT_SOURCE was a relative symlink, we need to resolve it relative to the path where the symlink file was located
+ done
+ DIR="$( cd -P "$( dirname "$SCRIPT_SOURCE" )" >/dev/null 2>&1 && pwd )"
+ echo 'DIR of script:' $DIR
+}
+
+
+if [[ "$1" != "check" && "$1" != "config" && "$1" != "build" && "$1" != "start" && "$1" != "stop" && "$1" != "down" ]]; then
+ echo "Unknown command '$1', specify one of the following: check|config|build|start|stop|down"
+else
+ # echo this script name and the tier expected for Docker deployment
+ tier_check
+
+ # Always show the script dir
+ get_dir_of_this_script
+
+ # Always export and show the version
+ export_version
+
+ # Always show the build in case branch changed or new commits
+ generate_build_version
+
+ # Print empty line
+ echo
+
+ if [ "$1" = "check" ]; then
+ # Bash array
+ config_paths=(
+ 'hubmap-auth/src/instance/app.cfg'
+ )
+
+ for pth in "${config_paths[@]}"; do
+ if [ ! -e $pth ]; then
+ echo "Missing file (relative path to DIR of script) :$pth"
+ exit 1
+ fi
+ done
+
+ echo 'Checks complete, all good :)'
+ elif [ "$1" = "config" ]; then
+ docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway config
+ elif [ "$1" = "build" ]; then
+ # Delete old VERSION and BUILD files if found
+ if [ -f "hubmap-auth/VERSION" ]; then
+ rm -rf hubmap-auth/VERSION
+ fi
+
+ if [ -f "hubmap-auth/BUILD" ]; then
+ rm -rf hubmap-auth/BUILD
+ fi
+
+ # Copy over the VERSION and BUILD files
+ cp ./VERSION hubmap-auth
+ cp ./BUILD hubmap-auth
+
+ docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway build --no-cache
+ elif [ "$1" = "start" ]; then
+ docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway up -d
+ elif [ "$1" = "stop" ]; then
+ docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway stop
+ elif [ "$1" = "down" ]; then
+ docker compose -f docker-compose.yml -f docker-compose.${DEPLOY_TIER}.yml -p gateway down
+ fi
+fi
diff --git a/hubmap-auth/hubmap-authLocalhostArchitecture.pdf b/hubmap-auth/hubmap-authLocalhostArchitecture.pdf
new file mode 100644
index 0000000..35ff891
Binary files /dev/null and b/hubmap-auth/hubmap-authLocalhostArchitecture.pdf differ
diff --git a/hubmap-auth/hubmap-authLocalhostArchitecture.svg b/hubmap-auth/hubmap-authLocalhostArchitecture.svg
new file mode 100644
index 0000000..a32d27a
--- /dev/null
+++ b/hubmap-auth/hubmap-authLocalhostArchitecture.svg
@@ -0,0 +1,597 @@
+
+
diff --git a/hubmap-auth/src/app.py b/hubmap-auth/src/app.py
index be6f55f..ed20249 100644
--- a/hubmap-auth/src/app.py
+++ b/hubmap-auth/src/app.py
@@ -14,6 +14,9 @@
from pathlib import Path
from urllib.parse import urlparse, parse_qs
+# local imports
+from endpoint_authorizer import EndpointAuthorizer
+
# HuBMAP commons
from hubmap_commons.hm_auth import AuthHelper
from hubmap_commons.exceptions import HTTPException
@@ -23,7 +26,9 @@
# All the API logging is forwarded to the uWSGI server and gets written into the log file `uwsgi-hubmap-auth.log`
# Log rotation is handled via logrotate on the host system with a configuration file
# Do NOT handle log file and rotation via the Python logging to avoid issues with multi-worker processes
-logging.basicConfig(format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s', level=logging.DEBUG, datefmt='%Y-%m-%d %H:%M:%S')
+logging.basicConfig(format='[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
+ , level=logging.DEBUG
+ , datefmt='%Y-%m-%d %H:%M:%S')
logger = logging.getLogger(__name__)
# Specify the absolute path of the instance folder and use the config file relative to the instance path
@@ -76,6 +81,12 @@
# Log the full stack trace, prepend a line with our message
logger.exception(msg)
+####################################################################################################
+## EndpointAuthorizer instance instantiation
+####################################################################################################
+
+# Module-level singleton - import and use directly in app.py
+endpoint_auth_instance = EndpointAuthorizer(auth_helper_instance)
####################################################################################################
## Default route
@@ -108,7 +119,6 @@ def cache_clear():
logger.info("All gateway API Auth function cache cleared.")
return "All function cache cleared."
-
# Auth for private API services
# All endpoints access need to be authenticated
# Direct access will see the JSON message
@@ -142,10 +152,14 @@ def api_auth():
authority = request.headers.get("Host")
method = request.headers.get("X-Original-Request-Method")
endpoint = request.headers.get("X-Original-URI")
+ # Grab the IP address the request came from for logging. If there is a comma-separated
+ # list in X-Forwarded-For, strip off everything but the original client IP
+ forwarded_for = request.headers.get('X-Forwarded-For')
+ req_ip_addr = forwarded_for.split(',')[0].strip() if forwarded_for else request.remote_addr
# method and endpoint are always not None as long as authority is not None
if authority is not None:
- # Load endpoints from json
+ # Load endpoints from JSON file, using method decorated with @cached()
data = load_file(app.config['API_ENDPOINTS_FILE'])
if authority in data.keys():
@@ -156,9 +170,25 @@ def api_auth():
target_endpoint = endpoint.split("?")[0]
# Remove trailing slash for comparison
if item['endpoint'].strip('/') == target_endpoint.strip('/'):
- if api_access_allowed(item, request):
+
+ if endpoint_auth_instance.api_access_allowed( item=item
+ , request=request):
+ endpoint_auth_instance.log_auth_decision(response_code=200
+ , response_length=len(response_200.data)
+ , auth_req_api=authority
+ , method=method
+ , endpoint=endpoint
+ , client_ip=req_ip_addr
+ , matched_pattern=item['endpoint'])
return response_200
else:
+ endpoint_auth_instance.log_auth_decision(response_code=401
+ , response_length=len(response_401.data)
+ , auth_req_api=authority
+ , method=method
+ , endpoint=endpoint
+ , client_ip=req_ip_addr
+ , matched_pattern=item['endpoint'])
return response_401
# Second pass, loop through the list to do the wildcard match
@@ -173,19 +203,61 @@ def api_auth():
target_endpoint = endpoint.split("?")[0]
# Remove trailing slash for comparison
if re.fullmatch(endpoint_pattern.strip('/'), target_endpoint.strip('/')) is not None:
- if api_access_allowed(item, request):
+ if endpoint_auth_instance.api_access_allowed( item=item
+ , request=request):
+ endpoint_auth_instance.log_auth_decision(response_code=200
+ , response_length=len(response_200.data)
+ , auth_req_api=authority
+ , method=method
+ , endpoint=endpoint
+ , client_ip=req_ip_addr
+ , matched_pattern=item['endpoint'])
+
return response_200
else:
+ endpoint_auth_instance.log_auth_decision(response_code=401
+ , response_length=len(response_401.data)
+ , auth_req_api=authority
+ , method=method
+ , endpoint=endpoint
+ , client_ip=req_ip_addr
+ , matched_pattern=item['endpoint'])
return response_401
- # After two passes and still no match found
- # It could be either unknown request method or unknown path
- return response_401
-
- # Handle the cases when authority not in data.keys()
+ # With no match to the combination of endpoint path and
+ # endpoint HTTP method, the proper response is an HTTP 404.
+ # Due to limitations in nginx's response codes, delegate generating
+ # a 404 Response back to the authority API by authorizing access with
+ # an HTTP 200 response to this subrequest.
+ endpoint_auth_instance.log_auth_decision(response_code=200
+ , response_length=len(response_200.data)
+ , auth_req_api=authority
+ , method=method
+ , endpoint=endpoint
+ , client_ip=req_ip_addr
+ , matched_pattern="NO_MATCHED_ENDPOINT_DELEGATED_TO_API")
+ delegate_404_response = make_response(jsonify({"message": "Endpoint not configured in gateway"}), 200)
+ delegate_404_response.headers['X-Delegate-404-Response'] = f"{authority} should send HTTP 404 Response"
+ return delegate_404_response
+
+ # Handle the cases when authority not in data.keys()
+ endpoint_auth_instance.log_auth_decision(response_code=401
+ , response_length=len(response_401.data)
+ , auth_req_api=authority
+ , method=method
+ , endpoint=endpoint
+ , client_ip=req_ip_addr
+ , matched_pattern="NO_MATCHED_AUTHORITY")
return response_401
else:
# Missing lookup_key
+ endpoint_auth_instance.log_auth_decision(response_code=401
+ , response_length=len(response_401.data)
+ , auth_req_api=authority
+ , method=method
+ , endpoint=endpoint
+ , client_ip=req_ip_addr
+ , matched_pattern="UNKNOWN_SERVICE")
return response_401
@@ -478,7 +550,6 @@ def get_status_data():
# Final result
return status_data
-
# Get user information dict based on the http request(headers)
# `group_required` is a boolean, when True, 'hmgroupids' is in the output
def get_user_info_for_access_check(request, group_required):
@@ -726,64 +797,6 @@ def validate_umls_key(umls_key):
else:
return False
-
-# Always pass through the requests with using modified version of the globus app secret as internal token
-def is_secrect_token(request):
- internal_token = auth_helper_instance.getProcessSecret()
- parsed_token = None
-
- if 'Authorization' in request.headers:
- auth_header = request.headers['Authorization']
- parsed_token = auth_header[6:].strip()
-
- if internal_token == parsed_token:
- return True
-
- return False
-
-
-# Check if access to the given endpoint item is allowed
-# Also check if the globus token associated user is a member of the specified group associated with the endpoint item
-def api_access_allowed(item, request):
- logger.info("======Matched endpoint======")
- logger.info(item)
-
- # Check if auth is required for this endpoint
- if item['auth'] == False:
- return True
-
- # Check if using modified version of the globus app secret as internal token
- if is_secrect_token(request):
- return True
-
- # When auth is required, we need to check if group access is also required
- group_required = True if 'groups' in item else False
-
- # Get user info and do further parsing
- user_info = get_user_info_for_access_check(request, group_required)
-
- logger.info("======user_info======")
- logger.info(user_info)
-
- # If returns error response, invalid header or token
- if isinstance(user_info, Response):
- return False
-
- # Otherwise, user_info is a dict and we check if the group ID of target endpoint can be found
- # in user_info['hmgroupids'] list
- # Key 'hmgroupids' presents only when group_required is True
- if group_required:
- for group in user_info['hmgroupids']:
- if group in item['groups']:
- return True
-
- # None of the assigned groups match the group ID specified in item['groups']
- return False
-
- # When no group access required and user_info dict gets returned
- return True
-
-
# If the given uuid is a file uuid, get the parent entity uuid
# If the given uuid itself is an entity uuid, just return it
# The bool entity_is_avr is returned as a flag
diff --git a/hubmap-auth/src/endpoint_authorizer.py b/hubmap-auth/src/endpoint_authorizer.py
new file mode 100644
index 0000000..bda09ce
--- /dev/null
+++ b/hubmap-auth/src/endpoint_authorizer.py
@@ -0,0 +1,234 @@
+"""
+EndpointAuthorizer - evaluates whether a request is authorized to access
+a matched API endpoint item.
+
+Usage in app.py:
+ from endpoint_authorizer import endpoint_authorizer
+
+ if endpoint_authorizer.api_access_allowed(item, request):
+ ...
+"""
+
+import logging
+import warnings
+import time
+from datetime import datetime, timezone
+
+# HuBMAP commons
+from hubmap_commons.hm_auth import AuthHelper
+
+# local imports
+import gateway_exceptions as gwEx
+
+# For the hooks used to log endpoint usage, set the level to use while
+# logging these events by using an unnamed level as close to the configured
+# level, but high enough to log.
+_ENDPOINT_LOG_LEVEL_NAME = "API_USAGE"
+_MAX_SEARCH = 5
+
+def _find_available_level(start_level: int, max_level: int) -> int | None:
+ """Return the first unnamed level at or above logger's effective level.
+
+ A level is considered unnamed if logging.getLevelName() returns the
+ default 'Level ' string that Python assigns to unregistered levels.
+ """
+ for candidate in range(start_level, max_level):
+ if logging.getLevelName(candidate) == f"Level {candidate}":
+ return candidate
+ return None
+
+def _register_endpoint_log_level(logger: logging.Logger) -> int:
+ """Register API_USAGE at the first available level at or above the
+ logger's effective level. Returns the registered level."""
+ level = _find_available_level(start_level = logger.getEffectiveLevel()
+ , max_level = logger.getEffectiveLevel()+_MAX_SEARCH)
+ if level is None:
+ effective = logger.getEffectiveLevel()
+ warnings.warn(message = f"Could not find an unnamed log level in range "
+ f"[{effective}, {effective + _MAX_SEARCH - 1}]. "
+ f"Falling back to the logger's effective level "
+ f" ({effective}) named '{logging.getLevelName(effective)}' ")
+ logger.critical(f"Endpoint usage log message will not be emitted with a level named "
+ f" '{_ENDPOINT_LOG_LEVEL_NAME}', but will be emitted as '{logging.getLevelName(effective)}'")
+ return effective
+ else:
+ logging.addLevelName(level, _ENDPOINT_LOG_LEVEL_NAME)
+ logger.info(f"Set the endpoint usage log level to "
+ f"{level}, emitting with the name {logging.getLevelName(level)}.")
+ return level
+
+class EndpointAuthorizer:
+
+ def __init__(self, ahi:AuthHelper):
+ self.logger = logging.getLogger('gateway')
+ self.effective_endpoint_log_level = _register_endpoint_log_level(self.logger)
+ self._auth_functions = {
+ 'read': self._handle_read_auth,
+ 'create': self._handle_write_auth,
+ 'data-admin': self._handle_data_admin_auth,
+ 'pipeline-test': self._handle_pipeline_testing_auth
+ }
+ self._auth_helper_instance: AuthHelper = ahi
+
+ # ------------------------------------------------------------------
+ # Authorization handlers
+ # Each accepts a request and returns bool.
+ # Stubs - replace with real logic as handlers are expanded.
+ # ------------------------------------------------------------------
+
+ def _handle_read_auth(self, request_token) -> bool:
+ return self._auth_helper_instance.has_read_privs(request_token)
+
+ def _handle_write_auth(self, request_token) -> bool:
+ return self._auth_helper_instance.has_write_privs(request_token)
+
+ def _handle_data_admin_auth(self, request_token) -> bool:
+ return self._auth_helper_instance.has_data_admin_privs(request_token)
+
+ def _handle_pipeline_testing_auth(self, request_token) -> bool:
+ return self._auth_helper_instance.has_pipeline_testing_privs(request_token)
+
+ def _handle_unknown_auth(self, auth_type: str) -> bool:
+ raise ValueError(f"Authorization method '{auth_type}' not supported")
+
+ # Indicate if the token parsed from the request is the secret, internal Globus token
+ def _is_secret_token(self, request_token) -> bool:
+ internal_token = self._auth_helper_instance.getProcessSecret()
+
+ return internal_token == request_token
+
+ # Pull the token from the Request
+ def _get_request_bearer_token(self, request) -> bool:
+ if 'Authorization' not in request.headers:
+ raise gwEx.GWNoAuthHeaderException('Missing Authorization header')
+ if not request.headers['Authorization'].upper().startswith('BEARER '):
+ raise gwEx.GWNoBearerSchemeException('Missing Bearer token')
+ return request.headers['Authorization'][6:].strip()
+
+ # ------------------------------------------------------------------
+ # Public interface
+ # ------------------------------------------------------------------
+
+ def api_access_allowed(self, item, request) -> bool:
+ """Return True if the request is authorized to access the endpoint item.
+
+ Access is granted unconditionally when:
+ - the item has no 'authorizer' key, or
+ - the request carries the Globus internal secret token.
+
+ Otherwise, the appropriate handler for item['authorizer'] is invoked.
+ Raises ValueError if the authorizer type is unrecognized.
+ """
+ self.logger.info("======Matched endpoint======")
+ self.logger.info(item)
+
+ determination_log_msg = f"Authorization from api_access_allowed is'" \
+ f" '_DETERMINATION_' for" \
+ f" {request.headers.get("Host")}," \
+ f" {request.headers.get("X-Original-Request-Method")}," \
+ f" {request.headers.get("X-Original-URI")}."
+
+ # If no authorizer is identified for the endpoint, access is allowed
+ if 'authorizer' not in item:
+ self.logger.debug(determination_log_msg.replace('_DETERMINATION_', f"{True}"))
+ return True
+
+ try:
+ token = self._get_request_bearer_token(request=request)
+ except (gwEx.GWNoAuthHeaderException, gwEx.GWNoBearerSchemeException) as gwe:
+ self.logger.debug(f"Setting access_allowed_determination to False due to token problem:"
+ f" '{gwe.message}'")
+ access_allowed_determination = False
+ self.logger.debug(determination_log_msg.replace('_DETERMINATION_', f"{access_allowed_determination}"))
+ return access_allowed_determination
+
+ # Allow access for any request presenting the secret, internal Globus token
+ if self._is_secret_token(token):
+ self.logger.debug(determination_log_msg.replace('_DETERMINATION_', f"{True}"))
+ return True
+
+ auth_handler = self._auth_functions.get(item['authorizer'])
+ if auth_handler is None:
+ self.logger.error(f"Authorization method '{item['authorizer']}' configured for"
+ f" {request.headers.get("Host")},"
+ f" {request.headers.get("X-Original-Request-Method")},"
+ f" {request.headers.get("X-Original-URI")} "
+ f" is not supported.")
+ self._handle_unknown_auth(item['authorizer'])
+ access_allowed_determination = auth_handler(request_token=token)
+ if not isinstance(access_allowed_determination, bool):
+ self.logger.debug( f"Based on auth_handler returning "
+ f" '{access_allowed_determination}'"
+ f" setting access_allowed_determination to False.")
+ access_allowed_determination = False
+ self.logger.debug(determination_log_msg.replace('_DETERMINATION_', f"{access_allowed_determination}"))
+ return access_allowed_determination
+
+ def log_auth_decision(self, response_code, response_length, auth_req_api, method, endpoint, client_ip, matched_pattern=None):
+ """
+ Log authorization decision in Common Log Format.
+
+ Logs each authorization check with details about the request and decision.
+ Format matches entity-api's after_request logging style.
+
+ Args:
+ response_code: HTTP status code (200 for authorized, 401 for denied)
+ auth_req_api: The service/host making the request (e.g., "entity-api")
+ method: HTTP method (GET, POST, PUT, DELETE)
+ endpoint: The original endpoint being accessed (e.g., "/entities/abc123")
+ matched_pattern: The pattern that matched from api_endpoints.json (e.g., "/entities/<*>")
+ or special values: "NO_MATCH", "UNKNOWN_SERVICE", "MISSING_HEADERS"
+ """
+ # Bail out immediately if the log statement about to be build would not be emitted.
+ if not self.logger.isEnabledFor(self.effective_endpoint_log_level):
+ return
+
+ # Assume caller has extracted a single IP address using the X-Forwarded-For header, but
+ # switch to log a conventional '-' if it is not set.
+ client_ip = client_ip or '-'
+
+ # Caller - not available without AWS IAM, use '-'
+ caller = '-'
+
+ # User - not available at authorization time (token not yet validated)
+ # Could extract from Authorization header if needed, but use '-' for now
+ user = '-'
+
+ # Request time in AWS/Apache format: [DD/MMM/YYYY:HH:MM:SS +0000]
+ request_time = datetime.now(timezone.utc).strftime('%d/%b/%Y:%H:%M:%S +0000')
+
+ # HTTP method and resource path from parameters
+ # method and endpoint are passed as parameters (not from Flask request object)
+ # because they come from X-Original-* headers, not the /api_auth request itself
+
+ # Protocol - assume HTTP/1.1 for auth requests
+ protocol = 'HTTP/1.1'
+ #
+ # # Response length - auth responses are small JSON messages (~34 bytes for 401, ~26 for 200)
+ # # Use approximate sizes or '-'
+ # response_length = 34 if response_code == 401 else 26
+
+ # Add matched pattern as additional info if available
+ # This helps understand which rule was applied
+ pattern_info = f"pattern={matched_pattern}" if matched_pattern else ""
+
+ # Add the API originating the authorization as additional info need by API Usage reporting
+ authority_info = f"authority={auth_req_api}" if auth_req_api else ""
+
+ # Format log message matching Common Log Format, aligned with these AWS API Gateway fields for
+ # custom access logs:
+ # $sourceIp $caller $user [$requestTime] "$method $resourcePath $protocol" $status $responseLength
+ log_message = (
+ f'{client_ip}' # aligned with AWS API Gateway $sourceIp
+ f' {caller}' # aligned with AWS API Gateway $caller
+ f' {user}' # aligned with AWS API Gateway $user
+ f' [{request_time}]' # aligned with AWS API Gateway $requestTime
+ f' "{method} {endpoint} {protocol}"' # aligned with AWS API Gateway $method $resourcePath $protocol
+ f' {response_code}' # aligned with AWS API Gateway $status
+ f' {response_length}'
+ f' {pattern_info}' # Supplemental data for reporting, beyond Common Log Format
+ f' {authority_info}' # Supplemental data for reporting, beyond Common Log Format
+ )
+
+ self.logger.log(level=self.effective_endpoint_log_level
+ , msg=log_message)
diff --git a/hubmap-auth/src/gateway_exceptions.py b/hubmap-auth/src/gateway_exceptions.py
new file mode 100644
index 0000000..c8934cf
--- /dev/null
+++ b/hubmap-auth/src/gateway_exceptions.py
@@ -0,0 +1,20 @@
+# Exceptions used internally by the service, typically for anticipated exceptions.
+# Knowledge of Flask, HTTP codes, and formatting of the Response should be
+# closer to the endpoint @app.route() methods rather than throughout service.
+class GWConfigurationException(Exception):
+ """Exception raised when problems loading the service configuration are encountered."""
+ def __init__(self, message='There were problems loading the configuration for the service.'):
+ self.message = message
+ super().__init__(self.message)
+
+class GWNoAuthHeaderException(Exception):
+ """Exception raised when authorization header is not present in the request."""
+ def __init__(self, message='Missing Authorization header.'):
+ self.message = message
+ super().__init__(self.message)
+
+class GWNoBearerSchemeException(Exception):
+ """Exception raised when a bearer token is not present in the request."""
+ def __init__(self, message='Missing Bearer token.'):
+ self.message = message
+ super().__init__(self.message)
diff --git a/nginx/conf.d-localhost/hubmap-auth.conf b/nginx/conf.d-localhost/hubmap-auth.conf
new file mode 100644
index 0000000..c6526c5
--- /dev/null
+++ b/nginx/conf.d-localhost/hubmap-auth.conf
@@ -0,0 +1,83 @@
+# Define the upstream hubmap-auth-server to be used by other API (on the same machine) nginx configs
+# This server will be accessed via `http://hubmap-auth-server/api_auth` in other conf files
+# We have to run the hubmap-auth service on a different local port to be used by other APIs
+# when deployed with multiple sub-domains pointing to the same machine with same IP
+upstream hubmap-auth-server {
+ server localhost:8000;
+}
+
+# Main gateway server - Port 7777 (mapped to host in docker-compose.localhost.yml)
+# This is the entry point for external requests to hubmap-auth
+server {
+ # This port matches the docker-compose port mapping: "7777:7777"
+ listen 7777;
+ server_name localhost gateway.localhost.hubmapconsortium.org;
+ root /usr/share/nginx/html;
+
+ # Logging to the mounted volume for outside container access
+ access_log /usr/src/app/log/nginx_access_gateway.log;
+ error_log /usr/src/app/log/nginx_error_gateway.log warn;
+
+ location = /favicon.ico {
+ alias /usr/share/nginx/html/favicon.ico;
+ }
+
+ # Pass requests to the uWSGI server using the "uwsgi" protocol on port 5000
+ location / {
+ # Always enable CORS
+ # Response to preflight requests
+ if ($request_method = 'OPTIONS') {
+ # The directive `add_header` doesn't work when response status code is 401, 403 or 500
+ # The `always` parameter is specified so the header field will be added regardless of the response code
+ add_header 'Access-Control-Allow-Origin' '*' always;
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, OPTIONS' always;
+
+ # Custom headers and headers various browsers should be OK with but aren't
+ add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,Authorization, MAuthorization,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
+
+ # Cache the response to this preflight request in browser for 24 hours (86400 seconds)
+ # without sending another preflight request
+ add_header 'Access-Control-Max-Age' 86400 always;
+
+ add_header 'Content-Type' 'text/plain; charset=utf-8' always;
+ add_header 'Content-Length' 0 always;
+ return 204;
+ }
+
+ # Response to the original requests (HTTP methods are case-sensitive) with CORS enabled
+ if ($request_method ~ (POST|GET|PUT)) {
+ add_header 'Access-Control-Allow-Origin' '*' always;
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, OPTIONS' always;
+ add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,Authorization, MAuthorization,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
+ add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
+ }
+
+ include uwsgi_params;
+ # Use IP v4 "127.0.0.1" instead of "localhost" to avoid 502 error caused by DNS failure
+ uwsgi_pass uwsgi://127.0.0.1:5000;
+ }
+}
+
+# Internal hubmap-auth service (used by other APIs on localhost via upstream)
+# Port 8000 is NOT exposed to host in docker-compose - it's only for internal container communication
+server {
+ listen 8000;
+ server_name localhost;
+ root /usr/share/nginx/html;
+
+ # We need this logging for inspecting auth requests from other internal services
+ # Logging to the mounted volume for outside container access
+ access_log /usr/src/app/log/nginx_access_hubmap-auth-server.log;
+ error_log /usr/src/app/log/nginx_error_hubmap-auth-server.log warn;
+
+ location = /favicon.ico {
+ alias /usr/share/nginx/html/favicon.ico;
+ }
+
+ # Pass requests to the uWSGI server using the "uwsgi" protocol on port 5000
+ location / {
+ include uwsgi_params;
+ # Use IP v4 "127.0.0.1" instead of "localhost" to avoid 502 error caused by DNS failure
+ uwsgi_pass uwsgi://127.0.0.1:5000;
+ }
+}
\ No newline at end of file
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..625ac17
--- /dev/null
+++ b/tests/README.md
@@ -0,0 +1,155 @@
+# HubMap-Auth Test Suite
+
+This directory contains all tests for the hubmap-auth service, organized by test type and deployment environment.
+
+## Directory Structure
+
+```
+test/
+├── README.md # This file - test suite overview
+├── localhost/ # Tests for localhost Docker deployment
+│ ├── integration/ # Integration tests with other services
+│ └── performance/ # Performance benchmarks (future)
+└── [existing test files] # Other test types
+```
+
+## Test Categories
+
+### Localhost Tests (`localhost/`)
+
+Tests for hubmap-auth running in Docker Desktop for local development and proof-of-concept deployments.
+
+**When to run:** Before pushing changes that affect localhost deployment, Docker configuration, or authorization logic.
+
+**See:** [localhost/README.md](localhost/README.md)
+
+### Integration Tests (`localhost/integration/`)
+
+End-to-end tests verifying hubmap-auth integrates correctly with other APIs (entity-api, ingest-api, etc.) over the `gateway_hubmap` Docker network.
+
+**See:** [localhost/integration/README.md](localhost/integration/README.md)
+
+### Performance Tests (`localhost/performance/`) - Future
+
+Load testing and performance benchmarks for localhost deployment.
+
+## Quick Start
+
+### Run All Tests
+
+```bash
+# Activate virtual environment
+source .venv/bin/activate
+
+# Run all tests
+python -m unittest discover -s test -v
+```
+
+### Run Localhost Integration Tests Only
+
+```bash
+source .venv/bin/activate
+python -m unittest discover -s test/localhost/integration -v
+```
+
+### Prerequisites
+
+1. **Docker containers running:**
+ ```bash
+ cd gateway
+ ./docker-localhost.sh start
+ docker ps | grep hubmap-auth # Should show "healthy"
+ ```
+
+2. **Python virtual environment:**
+Tests use the same dependencies as the main application:
+✅ Uses the exact same requests version as the application
+✅ No version conflicts or drift
+✅ Simpler - one source of truth for dependencies
+✅ Tests run with the same environment as the app
+```bash
+# Create virtual environment (first time only)
+python3 -m venv .venv
+
+# Activate virtual environment
+source .venv/bin/activate
+
+# Install application dependencies (includes requests)
+pip install -r hubmap-auth/src/requirements.txt
+```
+
+## CI/CD Integration
+
+These tests are designed to run in GitHub Actions or similar CI/CD systems. Example workflow:
+
+```yaml
+name: Localhost Integration Tests
+
+on: [push, pull_request]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Python
+ uses: actions/setup-python@v4
+ with:
+ python-version: '3.11'
+
+ - name: Create Docker network
+ run: docker network create gateway_hubmap
+
+ - name: Start hubmap-auth
+ run: |
+ cd gateway
+ ./docker-localhost.sh build
+ ./docker-localhost.sh start
+
+ - name: Wait for healthy status
+ run: timeout 60 bash -c 'until docker ps | grep hubmap-auth | grep healthy; do sleep 2; done'
+
+ - name: Install test dependencies
+ run: |
+ python -m venv .venv
+ source .venv/bin/activate
+ pip install -r test/localhost/integration/requirements.txt
+
+ - name: Run integration tests
+ run: |
+ source .venv/bin/activate
+ python -m unittest discover -s test/localhost/integration -v
+```
+
+## Contributing
+
+When adding new tests:
+
+1. **Choose the right directory** - Place tests in the appropriate subdirectory based on type
+2. **Follow existing patterns** - Match the style and structure of existing tests
+3. **Add documentation** - Update relevant README files
+4. **Keep tests independent** - Each test should run in isolation
+5. **Use descriptive names** - Test names should clearly indicate what they verify
+6. **Handle errors gracefully** - Provide actionable error messages
+
+## Test Execution Order
+
+Tests are discovered and run alphabetically by default. If execution order matters:
+
+1. Use `setUpClass` and `tearDownClass` for class-level setup
+2. Use `setUp` and `tearDown` for test-level setup
+3. Name test files to control discovery order if needed
+
+## Getting Help
+
+- **Test failures:** Check container logs with `docker logs hubmap-auth`
+- **Connection errors:** Verify containers are running with `docker ps`
+- **Import errors:** Ensure virtual environment is activated
+- **Docker issues:** Check Docker Desktop is running
+
+## Related Documentation
+
+- [HubMap-Auth Deployment Guide](../README.md)
+- [API Endpoints Configuration](../api_endpoints.localhost.json)
+- [Docker Compose Configuration](../docker-compose.localhost.yml)
diff --git a/tests/localhost/README.md b/tests/localhost/README.md
new file mode 100644
index 0000000..d5682c8
--- /dev/null
+++ b/tests/localhost/README.md
@@ -0,0 +1,183 @@
+# Localhost Testing for HubMap-Auth
+
+This directory contains tests for hubmap-auth running in Docker Desktop on localhost. These tests verify the service works correctly in a local development/proof-of-concept environment before deployment to higher tiers (DEV, TEST, PROD).
+
+## Purpose
+
+Localhost tests serve multiple purposes:
+
+1. **Pre-deployment verification** - Validate configuration changes before pushing to DEV
+2. **Local development** - Quick feedback during feature development
+3. **Proof-of-concept** - Demonstrate hubmap-auth integration with new APIs
+4. **Regression testing** - Ensure changes don't break existing functionality
+
+## Test Types
+
+### Integration Tests (`integration/`)
+
+End-to-end tests that verify hubmap-auth integrates correctly with other services over Docker networking.
+
+**What they test:**
+- Container startup and health
+- Authorization logic with `api_endpoints.localhost.json`
+- Communication with other APIs (entity-api, etc.)
+- nginx auth_request flow
+- Docker network connectivity
+
+**See:** [integration/README.md](integration/README.md)
+
+### Performance Tests (`performance/`) - Future
+
+Benchmarks and load tests for localhost deployment.
+
+**What they will test:**
+- Response time under load
+- Concurrent request handling
+- Memory usage patterns
+- Container resource limits
+
+## Prerequisites
+
+### 1. Docker Setup
+
+Create the shared Docker network (one-time setup):
+```bash
+docker network create gateway_hubmap
+```
+
+### 2. Build and Start Containers
+
+```bash
+cd gateway
+./docker-localhost.sh build
+./docker-localhost.sh start
+
+# Verify containers are healthy
+docker ps
+# hubmap-auth STATUS should show "healthy"
+```
+
+### 3. Python Environment
+Tests use the same dependencies as the main application:
+✅ Uses the exact same requests version as the application
+✅ No version conflicts or drift
+✅ Simpler - one source of truth for dependencies
+✅ Tests run with the same environment as the app
+```bash
+# Create virtual environment (first time only)
+python3 -m venv .venv
+
+# Activate virtual environment
+source .venv/bin/activate
+
+# Install application dependencies (includes requests)
+pip install -r hubmap-auth/src/requirements.txt
+```
+
+# HTTP client library for API testing
+```
+## Running Tests
+
+### All Localhost Tests
+
+```bash
+source .venv/bin/activate
+python -m unittest discover -s test/localhost -v
+```
+
+### Integration Tests Only
+
+```bash
+source .venv/bin/activate
+python -m unittest discover -s test/localhost/integration -v
+```
+
+### Specific Test File
+
+```bash
+source .venv/bin/activate
+python -m unittest test.localhost.integration.test_localhost_integration -v
+```
+
+## Environment Differences
+
+Localhost deployment differs from higher tiers in several ways:
+
+| Aspect | Localhost | DEV/TEST/PROD |
+|--------|-----------|---------------|
+| SSL/TLS | Disabled | Let's Encrypt certificates |
+| Ports | 7777 (custom) | 80, 443 (standard) |
+| Logging | Local files + Docker logs | CloudWatch Logs |
+| Auth endpoints | `api_endpoints.localhost.json` | `api_endpoints.{dev\|test\|prod}.json` |
+| Network | `gateway_hubmap` (Docker) | AWS VPC |
+| Service discovery | Docker DNS | AWS Route53/ELB |
+
+Tests in this directory account for these differences.
+
+## Debugging Failed Tests
+
+### Container Not Running
+
+```bash
+# Check container status
+docker ps -a | grep hubmap-auth
+
+# Check logs
+docker logs hubmap-auth
+
+# Restart if needed
+cd gateway
+./docker-localhost.sh down
+./docker-localhost.sh start
+```
+
+### Container Not Healthy
+
+```bash
+# Check health status
+docker inspect hubmap-auth | grep -A 10 Health
+
+# Common causes:
+# - Port 7777 already in use
+# - nginx configuration error
+# - Missing api_endpoints.localhost.json
+```
+
+### Connection Refused
+
+```bash
+# Verify port mapping
+docker port hubmap-auth
+
+# Test from host
+curl http://localhost:7777/status.json
+
+# Test from inside container
+docker exec hubmap-auth curl http://localhost:7777/status.json
+```
+
+### Docker Network Issues
+
+```bash
+# Inspect network
+docker network inspect gateway_hubmap
+
+# Verify containers are on the network
+docker network inspect gateway_hubmap | grep Name
+```
+
+## Adding New Test Types
+
+When adding new test categories:
+
+1. **Create subdirectory** under `test/localhost/`
+2. **Add README.md** explaining the test type and how to run
+3. **Add requirements.txt** if new dependencies needed
+4. **Update this README** to document the new test type
+5. **Follow best practices** from existing integration tests
+
+## Related Documentation
+
+- [Parent Test Suite Overview](../README.md)
+- [Docker Localhost Deployment](../../README.md)
+- [API Endpoints Configuration](../../api_endpoints.localhost.json)
diff --git a/tests/localhost/integration/README.md b/tests/localhost/integration/README.md
new file mode 100644
index 0000000..911ebf5
--- /dev/null
+++ b/tests/localhost/integration/README.md
@@ -0,0 +1,240 @@
+# HubMap-Auth Localhost Integration Tests
+
+Integration tests for hubmap-auth localhost deployment. These tests verify that hubmap-auth runs correctly in a Docker container and provides authorization services to other APIs on the `gateway_hubmap` Docker network.
+
+## Prerequisites
+
+### Running Containers
+The tests require hubmap-auth to be running in Docker:
+
+```bash
+cd gateway
+./docker-localhost.sh build
+./docker-localhost.sh start
+
+# Verify the container is healthy
+docker ps | grep hubmap-auth
+# STATUS should show "healthy"
+```
+
+### Python Environment
+Tests are designed to run in a Python virtual environment:
+
+```bash
+# Create virtual environment (first time only)
+python3 -m venv .venv
+
+# Activate virtual environment
+source .venv/bin/activate
+
+# Install dependencies (first time only)
+pip install requests
+```
+
+## Running the Tests
+
+### From gateway repository root
+
+```bash
+# Activate virtual environment
+source .venv/bin/activate
+
+# Run all localhost integration tests
+python -m unittest discover -s test/localhost/integration -p "test_*.py" -v
+```
+
+### Run specific test file
+
+```bash
+source .venv/bin/activate
+python -m unittest discover -s test/localhost/integration -p "test_localhost_integration.py" -v
+```
+
+### Run Specific Test Classes
+
+```bash
+source .venv/bin/activate
+python -m unittest test.localhost.integration.test_localhost_integration.HubMapAuthLocalhostTests -v
+python -m unittest test.localhost.integration.test_localhost_integration.HubMapAuthEndpointCoverage -v
+```
+
+### Run Individual Tests
+
+```bash
+source .venv/bin/activate
+python -m unittest test.localhost.integration.test_localhost_integration.HubMapAuthLocalhostTests.test_status_endpoint_responds -v
+```
+
+### Run with Verbose Output
+
+Add `-v` flag for detailed output:
+
+```bash
+python -m unittest discover -s test -p "test_localhost_integration.py" -v
+```
+
+## Test Structure
+
+### Test Classes
+
+**HubMapAuthLocalhostTests**
+- Core functionality tests for hubmap-auth
+- Validates status endpoint responds correctly
+- Tests `/api_auth` endpoint with various header combinations
+- Validates performance requirements (< 1 second response)
+
+**HubMapAuthEndpointCoverage**
+- Tests authorization logic against `api_endpoints.localhost.json`
+- Verifies public endpoints are accessible without authentication
+- Verifies protected endpoints require authentication
+- Uses parameterized subtests for comprehensive coverage
+
+### Key Test Methods
+
+- `test_status_endpoint_responds()` - Basic connectivity and response validation
+- `test_api_auth_with_valid_headers_public_endpoint()` - Public endpoint authorization
+- `test_api_auth_with_valid_headers_protected_endpoint_no_token()` - Protected endpoint blocking
+- `test_api_endpoints_json_valid_format()` - Configuration file validation
+
+## Best Practices Used
+
+### Code Quality
+- **Type hints** - All parameters and return types annotated for clarity
+- **Docstrings** - Every test has descriptive documentation
+- **Descriptive names** - Test names clearly describe what they verify
+- **Proper assertions** - Meaningful assertion messages for failures
+
+### Test Organization
+- **Class-level constants** - `BASE_URL`, `TIMEOUT` defined once and reused
+- **setUpClass** - Expensive setup (container checks) run once per class
+- **subTest** - Parameterized tests provide clear failure reporting per endpoint
+- **Focused tests** - Each test validates one specific behavior
+
+### Robustness
+- **Timeout handling** - All requests have explicit timeouts
+- **Connection error handling** - Graceful failure with helpful messages
+- **Conditional skipping** - Tests skip gracefully when Docker unavailable
+- **Clear error messages** - Failures indicate exactly what went wrong and how to fix
+
+### CI/CD Ready
+- **No external dependencies** - Uses only standard library + requests
+- **Subprocess isolation** - Docker commands use subprocess with timeout
+- **Exit codes** - Proper test success/failure reporting
+- **Environment agnostic** - Works in local development and CI pipelines
+
+## Test Coverage
+
+### What These Tests Verify
+
+✅ hubmap-auth container starts and becomes healthy
+✅ Status endpoint responds with valid JSON
+✅ `/api_auth` endpoint validates required headers
+✅ Authorization logic checks `api_endpoints.localhost.json`
+✅ Public endpoints allow access without authentication
+✅ Protected endpoints block access without authentication
+✅ Unknown services are rejected
+✅ Missing headers are properly handled
+✅ Configuration file has valid JSON structure
+
+### What These Tests Don't Cover
+
+❌ Token validation with real Globus tokens (requires valid credentials)
+❌ Group membership validation (requires test users in specific groups)
+❌ Load testing / performance under stress
+❌ Security penetration testing
+❌ Multi-container orchestration failures
+
+## Troubleshooting
+
+### "Cannot connect to hubmap-auth"
+**Cause:** Container not running or not accessible
+**Solution:**
+```bash
+cd gateway
+./docker-localhost.sh start
+docker ps | grep hubmap-auth
+```
+
+### "hubmap-auth not ready: status.json returned 500"
+**Cause:** Container running but application not initialized
+**Solution:** Check container logs for errors
+```bash
+docker logs hubmap-auth
+```
+
+### "Docker not available - skipping test"
+**Cause:** Docker commands failing or timing out
+**Solution:** Verify Docker is running
+```bash
+docker --version
+docker ps
+```
+
+### Tests hang or timeout
+**Cause:** Network connectivity issues between host and containers
+**Solution:** Verify port mappings and container networking
+```bash
+docker port hubmap-auth
+curl http://localhost:7777/status.json
+```
+
+## Future Enhancements
+
+### Pytest Migration (Optional)
+While these tests use Python's built-in `unittest`, you can optionally migrate to pytest for additional features:
+
+**Benefits of pytest:**
+- More concise syntax with simple `assert` statements
+- Better parameterized testing with `@pytest.mark.parametrize`
+- Richer output formatting and failure reporting
+- Extensive plugin ecosystem (coverage, parallel execution, etc.)
+- Fixture system for complex setup/teardown
+
+**Migration effort:** Low - pytest runs unittest tests without modification
+
+**To use pytest (optional):**
+```bash
+pip install pytest
+pytest test/test_localhost_integration.py -v
+```
+
+**Recommendation:** Stick with unittest for now unless you need pytest-specific features. Unittest is part of Python's standard library and sufficient for these integration tests.
+
+## Contributing
+
+When adding new tests:
+
+1. **Follow existing patterns** - Use the same class structure and naming conventions
+2. **Add docstrings** - Every test should explain what it validates
+3. **Use subTest for parameters** - When testing multiple similar cases
+4. **Handle failures gracefully** - Provide actionable error messages
+5. **Keep tests independent** - Each test should work in isolation
+6. **Update this README** - Document new test classes or significant changes
+
+## CI/CD Integration
+
+These tests are designed to run in GitHub Actions or similar CI/CD systems:
+
+```yaml
+# Example GitHub Actions workflow
+- name: Start hubmap-auth
+ run: |
+ cd gateway
+ ./docker-localhost.sh build
+ ./docker-localhost.sh start
+
+- name: Wait for healthy status
+ run: |
+ timeout 60 bash -c 'until docker ps | grep hubmap-auth | grep healthy; do sleep 2; done'
+
+- name: Run integration tests
+ run: |
+ source .venv/bin/activate
+ python -m unittest discover -s test -p "test_localhost_integration.py" -v
+```
+
+## Related Documentation
+
+- [Docker Deployment Guide](../README.md) - How to deploy hubmap-auth locally
+- [API Endpoints Configuration](../api_endpoints.localhost.json) - Authorization configuration
+- [HubMap-Auth API Documentation](../hubmap-auth/README.md) - API reference
diff --git a/tests/localhost/integration/gateway_test_readme.md b/tests/localhost/integration/gateway_test_readme.md
new file mode 100644
index 0000000..5bdc50f
--- /dev/null
+++ b/tests/localhost/integration/gateway_test_readme.md
@@ -0,0 +1,268 @@
+# HubMap-Auth Localhost Integration Tests
+
+Integration tests for hubmap-auth localhost deployment. These tests verify that hubmap-auth runs correctly in a Docker container and provides authorization services to other APIs on the `gateway_hubmap` Docker network.
+
+## Test Files
+
+This directory contains tests organized by functionality:
+
+- **test_endpoints_public.py** - Public endpoints (no auth required)
+- **test_endpoints_protected.py** - Protected endpoints (auth required)
+- **test_authorization.py** - Authorization logic (/api_auth behavior)
+- **test_configuration.py** - Configuration file validation
+- **test_cors.py** - CORS headers and preflight
+- **test_cache.py** - Cache management endpoints
+
+Files are named to group together alphabetically by purpose (all `test_endpoints_*` files group together, etc.).
+
+## Prerequisites
+
+### Running Containers
+The tests require hubmap-auth to be running in Docker:
+
+```bash
+cd gateway
+./docker-localhost.sh build
+./docker-localhost.sh start
+
+# Verify the container is healthy
+docker ps | grep hubmap-auth
+# STATUS should show "healthy"
+```
+
+### Python Environment
+Tests are designed to run in a Python virtual environment:
+
+```bash
+# Create virtual environment (first time only)
+python3 -m venv .venv
+
+# Activate virtual environment
+source .venv/bin/activate
+
+# Install dependencies (first time only)
+pip install requests
+```
+
+## Running the Tests
+
+### From gateway repository root
+
+```bash
+# Activate virtual environment
+source .venv/bin/activate
+
+# Run all localhost integration tests
+python -m unittest discover -s test/localhost/integration -p "test_*.py" -v
+```
+
+### Run specific test file
+
+```bash
+source .venv/bin/activate
+
+# Run all public endpoint tests
+python -m unittest tests.localhost.integration.test_endpoints_public -v
+
+# Run all protected endpoint tests
+python -m unittest tests.localhost.integration.test_endpoints_protected -v
+
+# Run all authorization tests
+python -m unittest tests.localhost.integration.test_authorization -v
+```
+
+### Run Specific Test Classes
+
+```bash
+source .venv/bin/activate
+
+# Run just public GET endpoint tests
+python -m unittest tests.localhost.integration.test_endpoints_public.EndpointsGETPublicTests -v
+
+# Run just protected DELETE endpoint tests
+python -m unittest tests.localhost.integration.test_endpoints_protected.EndpointsDELETEProtectedTests -v
+
+# Run just authorization header validation tests
+python -m unittest tests.localhost.integration.test_authorization.ApiAuthHeaderValidationTests -v
+```
+
+### Run Individual Tests
+
+```bash
+source .venv/bin/activate
+python -m unittest tests.localhost.integration.test_endpoints_public.EndpointsGETPublicTests.test_status_json_responds -v
+```
+
+### Run with Verbose Output
+
+Add `-v` flag for detailed output:
+
+```bash
+python -m unittest discover -s test -p "test_localhost_integration.py" -v
+```
+
+## Test Structure
+
+### Test Classes
+
+**HubMapAuthLocalhostTests**
+- Core functionality tests for hubmap-auth
+- Validates status endpoint responds correctly
+- Tests `/api_auth` endpoint with various header combinations
+- Validates performance requirements (< 1 second response)
+
+**HubMapAuthEndpointCoverage**
+- Tests authorization logic against `api_endpoints.localhost.json`
+- Verifies public endpoints are accessible without authentication
+- Verifies protected endpoints require authentication
+- Uses parameterized subtests for comprehensive coverage
+
+### Key Test Methods
+
+- `test_status_endpoint_responds()` - Basic connectivity and response validation
+- `test_api_auth_with_valid_headers_public_endpoint()` - Public endpoint authorization
+- `test_api_auth_with_valid_headers_protected_endpoint_no_token()` - Protected endpoint blocking
+- `test_api_endpoints_json_valid_format()` - Configuration file validation
+
+## Best Practices Used
+
+### Code Quality
+- **Type hints** - All parameters and return types annotated for clarity
+- **Docstrings** - Every test has descriptive documentation
+- **Descriptive names** - Test names clearly describe what they verify
+- **Proper assertions** - Meaningful assertion messages for failures
+
+### Test Organization
+- **Class-level constants** - `BASE_URL`, `TIMEOUT` defined once and reused
+- **setUpClass** - Expensive setup (container checks) run once per class
+- **subTest** - Parameterized tests provide clear failure reporting per endpoint
+- **Focused tests** - Each test validates one specific behavior
+
+### Robustness
+- **Timeout handling** - All requests have explicit timeouts
+- **Connection error handling** - Graceful failure with helpful messages
+- **Conditional skipping** - Tests skip gracefully when Docker unavailable
+- **Clear error messages** - Failures indicate exactly what went wrong and how to fix
+
+### CI/CD Ready
+- **No external dependencies** - Uses only standard library + requests
+- **Subprocess isolation** - Docker commands use subprocess with timeout
+- **Exit codes** - Proper test success/failure reporting
+- **Environment agnostic** - Works in local development and CI pipelines
+
+## Test Coverage
+
+### What These Tests Verify
+
+✅ hubmap-auth container starts and becomes healthy
+✅ Status endpoint responds with valid JSON
+✅ `/api_auth` endpoint validates required headers
+✅ Authorization logic checks `api_endpoints.localhost.json`
+✅ Public endpoints allow access without authentication
+✅ Protected endpoints block access without authentication
+✅ Unknown services are rejected
+✅ Missing headers are properly handled
+✅ Configuration file has valid JSON structure
+
+### What These Tests Don't Cover
+
+❌ Token validation with real Globus tokens (requires valid credentials)
+❌ Group membership validation (requires test users in specific groups)
+❌ Load testing / performance under stress
+❌ Security penetration testing
+❌ Multi-container orchestration failures
+
+## Troubleshooting
+
+### "Cannot connect to hubmap-auth"
+**Cause:** Container not running or not accessible
+**Solution:**
+```bash
+cd gateway
+./docker-localhost.sh start
+docker ps | grep hubmap-auth
+```
+
+### "hubmap-auth not ready: status.json returned 500"
+**Cause:** Container running but application not initialized
+**Solution:** Check container logs for errors
+```bash
+docker logs hubmap-auth
+```
+
+### "Docker not available - skipping test"
+**Cause:** Docker commands failing or timing out
+**Solution:** Verify Docker is running
+```bash
+docker --version
+docker ps
+```
+
+### Tests hang or timeout
+**Cause:** Network connectivity issues between host and containers
+**Solution:** Verify port mappings and container networking
+```bash
+docker port hubmap-auth
+curl http://localhost:7777/status.json
+```
+
+## Future Enhancements
+
+### Pytest Migration (Optional)
+While these tests use Python's built-in `unittest`, you can optionally migrate to pytest for additional features:
+
+**Benefits of pytest:**
+- More concise syntax with simple `assert` statements
+- Better parameterized testing with `@pytest.mark.parametrize`
+- Richer output formatting and failure reporting
+- Extensive plugin ecosystem (coverage, parallel execution, etc.)
+- Fixture system for complex setup/teardown
+
+**Migration effort:** Low - pytest runs unittest tests without modification
+
+**To use pytest (optional):**
+```bash
+pip install pytest
+pytest test/test_localhost_integration.py -v
+```
+
+**Recommendation:** Stick with unittest for now unless you need pytest-specific features. Unittest is part of Python's standard library and sufficient for these integration tests.
+
+## Contributing
+
+When adding new tests:
+
+1. **Follow existing patterns** - Use the same class structure and naming conventions
+2. **Add docstrings** - Every test should explain what it validates
+3. **Use subTest for parameters** - When testing multiple similar cases
+4. **Handle failures gracefully** - Provide actionable error messages
+5. **Keep tests independent** - Each test should work in isolation
+6. **Update this README** - Document new test classes or significant changes
+
+## CI/CD Integration
+
+These tests are designed to run in GitHub Actions or similar CI/CD systems:
+
+```yaml
+# Example GitHub Actions workflow
+- name: Start hubmap-auth
+ run: |
+ cd gateway
+ ./docker-localhost.sh build
+ ./docker-localhost.sh start
+
+- name: Wait for healthy status
+ run: |
+ timeout 60 bash -c 'until docker ps | grep hubmap-auth | grep healthy; do sleep 2; done'
+
+- name: Run integration tests
+ run: |
+ source .venv/bin/activate
+ python -m unittest discover -s test -p "test_localhost_integration.py" -v
+```
+
+## Related Documentation
+
+- [Docker Deployment Guide](../README.md) - How to deploy hubmap-auth locally
+- [API Endpoints Configuration](../api_endpoints.localhost.json) - Authorization configuration
+- [HubMap-Auth API Documentation](../hubmap-auth/README.md) - API reference
diff --git a/tests/localhost/integration/test_endpoints_protected.py b/tests/localhost/integration/test_endpoints_protected.py
new file mode 100644
index 0000000..7e865e3
--- /dev/null
+++ b/tests/localhost/integration/test_endpoints_protected.py
@@ -0,0 +1,258 @@
+"""
+Tests for protected hubmap-auth endpoints requiring authentication.
+
+Protected endpoints should block access without valid tokens and proper
+group membership. These tests verify authorization enforcement.
+
+Run all protected endpoint tests:
+ python -m unittest tests.localhost.integration.test_endpoints_protected -v
+
+Run specific HTTP method tests:
+ python -m unittest tests.localhost.integration.test_endpoints_protected.EndpointsGETProtectedTests -v
+ python -m unittest tests.localhost.integration.test_endpoints_protected.EndpointsPOSTProtectedTests -v
+"""
+
+import unittest
+import requests
+
+
+class EndpointsGETProtectedTests(unittest.TestCase):
+ """Test protected GET endpoints that require authentication."""
+
+ BASE_URL = "http://localhost:7777"
+ TIMEOUT = 10
+
+ def test_usergroups_blocks_without_token(self):
+ """Test that GET /usergroups requires authentication via entity-api."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/usergroups",
+ "X-Original-Request-Method": "GET"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_datasets_unpublished_blocks_without_token(self):
+ """Test that GET /datasets/unpublished requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/datasets/unpublished",
+ "X-Original-Request-Method": "GET"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_descendants_blocks_without_token(self):
+ """Test that GET /descendants/ requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/descendants/test-id",
+ "X-Original-Request-Method": "GET"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_children_blocks_without_token(self):
+ """Test that GET /children/ requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/children/test-id",
+ "X-Original-Request-Method": "GET"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_previous_revisions_blocks_without_token(self):
+ """Test that GET /previous_revisions/ requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/previous_revisions/test-id",
+ "X-Original-Request-Method": "GET"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_next_revisions_blocks_without_token(self):
+ """Test that GET /next_revisions/ requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/next_revisions/test-id",
+ "X-Original-Request-Method": "GET"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+
+class EndpointsPOSTProtectedTests(unittest.TestCase):
+ """Test protected POST endpoints that require authentication."""
+
+ BASE_URL = "http://localhost:7777"
+ TIMEOUT = 10
+
+ def test_datasets_components_blocks_without_token(self):
+ """Test that POST /datasets/components requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/datasets/components",
+ "X-Original-Request-Method": "POST"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_entities_multiple_samples_blocks_without_token(self):
+ """Test that POST /entities/multiple-samples/ requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/entities/multiple-samples/5",
+ "X-Original-Request-Method": "POST"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_entities_create_blocks_without_token(self):
+ """Test that POST /entities/ requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/entities/sample",
+ "X-Original-Request-Method": "POST"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+
+class EndpointsPUTProtectedTests(unittest.TestCase):
+ """Test protected PUT endpoints that require authentication."""
+
+ BASE_URL = "http://localhost:7777"
+ TIMEOUT = 10
+
+ def test_entities_update_blocks_without_token(self):
+ """Test that PUT /entities/ requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/entities/test-uuid",
+ "X-Original-Request-Method": "PUT"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_datasets_retract_blocks_without_token(self):
+ """Test that PUT /datasets//retract requires admin authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/datasets/test-id/retract",
+ "X-Original-Request-Method": "PUT"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ # Requires Data Admin group
+ self.assertEqual(response.status_code, 401)
+
+
+class EndpointsDELETEProtectedTests(unittest.TestCase):
+ """Test protected DELETE endpoints that require authentication."""
+
+ BASE_URL = "http://localhost:7777"
+ TIMEOUT = 10
+
+ def test_flush_cache_blocks_without_token(self):
+ """Test that DELETE /flush-cache/ requires authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/flush-cache/test-id",
+ "X-Original-Request-Method": "DELETE"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_flush_all_cache_blocks_without_token(self):
+ """Test that DELETE /flush-all-cache requires admin authentication."""
+ headers = {
+ "Host": "entity-api",
+ "X-Original-URI": "/flush-all-cache",
+ "X-Original-Request-Method": "DELETE"
+ }
+
+ response = requests.get(
+ f"{self.BASE_URL}/api_auth",
+ headers=headers,
+ timeout=self.TIMEOUT
+ )
+
+ # Requires Data Admin group
+ self.assertEqual(response.status_code, 401)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/localhost/integration/test_public_endpoints.py b/tests/localhost/integration/test_public_endpoints.py
new file mode 100644
index 0000000..0dd028a
--- /dev/null
+++ b/tests/localhost/integration/test_public_endpoints.py
@@ -0,0 +1,105 @@
+"""
+Tests for public hubmap-auth endpoints accessible without authentication.
+
+Public endpoints should be accessible to anyone without requiring a token.
+These include status checks, informational endpoints, and read-only data.
+
+Run all public endpoint tests:
+ python -m unittest tests.localhost.integration.test_endpoints_public -v
+
+Run just GET tests:
+ python -m unittest tests.localhost.integration.test_endpoints_public.EndpointsGETPublicTests -v
+
+Run just POST tests:
+ python -m unittest tests.localhost.integration.test_endpoints_public.EndpointsPOSTPublicTests -v
+"""
+
+import unittest
+import requests
+from requests.exceptions import ConnectionError
+
+
+class EndpointsGETPublicTests(unittest.TestCase):
+ """Test public GET endpoints that don't require authentication."""
+
+ BASE_URL = "http://localhost:7777"
+ TIMEOUT = 10
+
+ @classmethod
+ def setUpClass(cls):
+ """Verify hubmap-auth is accessible before running tests."""
+ try:
+ response = requests.get(f"{cls.BASE_URL}/status.json", timeout=cls.TIMEOUT)
+ if response.status_code != 200:
+ raise RuntimeError(
+ f"hubmap-auth not ready: status.json returned {response.status_code}"
+ )
+ except ConnectionError as e:
+ raise RuntimeError(
+ f"Cannot connect to hubmap-auth at {cls.BASE_URL}. "
+ "Ensure container is running: cd gateway && ./docker-localhost.sh start"
+ ) from e
+
+ def test_status_json_responds(self):
+ """Test that /status.json endpoint returns valid status."""
+ response = requests.get(f"{self.BASE_URL}/status.json", timeout=self.TIMEOUT)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.headers["Content-Type"], "application/json")
+
+ data = response.json()
+ self.assertIsInstance(data, dict)
+ # Verify gateway section exists with version info
+ self.assertIn("gateway", data)
+ self.assertIn("version", data["gateway"])
+
+ def test_status_json_performance(self):
+ """Test that status endpoint responds within reasonable time."""
+ response = requests.get(f"{self.BASE_URL}/status.json", timeout=self.TIMEOUT)
+
+ # Multi-service status check may take longer than simple endpoints
+ # Allow up to 3 seconds for comprehensive health check
+ self.assertLess(response.elapsed.total_seconds(), 3.0)
+
+ def test_status_json_includes_service_status(self):
+ """Test that status includes information about dependent services."""
+ response = requests.get(f"{self.BASE_URL}/status.json", timeout=self.TIMEOUT)
+
+ data = response.json()
+ # Should include status for various services
+ self.assertIsInstance(data, dict)
+ # At minimum should have gateway info
+ self.assertGreater(len(data), 0)
+
+ def test_status_json_valid_content_type(self):
+ """Test that status endpoint returns proper JSON content type."""
+ response = requests.get(f"{self.BASE_URL}/status.json", timeout=self.TIMEOUT)
+
+ self.assertIn("application/json", response.headers.get("Content-Type", ""))
+
+ def test_status_json_handles_errors_gracefully(self):
+ """Test that status endpoint handles malformed requests gracefully."""
+ # Request with invalid query parameters
+ response = requests.get(
+ f"{self.BASE_URL}/status.json",
+ params={"invalid": "param"},
+ timeout=self.TIMEOUT
+ )
+
+ # Should still return 200 (ignore unknown params) or proper error code
+ self.assertIn(response.status_code, [200, 400])
+
+
+class EndpointsPOSTPublicTests(unittest.TestCase):
+ """Test public POST endpoints that don't require authentication."""
+
+ BASE_URL = "http://localhost:7777"
+ TIMEOUT = 10
+
+ # Placeholder for future public POST endpoints
+ # Currently hubmap-auth has no public POST endpoints
+ pass
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_get_status_info.py b/tests/test_get_status_info.py
deleted file mode 100644
index 2252a3d..0000000
--- a/tests/test_get_status_info.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import json
-from http import HTTPStatus
-from unittest.mock import patch, MagicMock
-from app import _get_status_info
-import requests
-
-def make_response(status, headers, text):
- mock = MagicMock()
- mock.status_code = status
- mock.headers = headers
- mock.text = text
- def json_loader():
- return json.loads(text)
- mock.json.side_effect = json_loader
- return mock
-
-@patch("requests.get")
-def test_json_response(mock_get):
- mock_get.return_value = make_response(
- status=200,
- headers={"Content-Type": "application/json"},
- text='{"service": "ok"}'
- )
-
- result = _get_status_info("http://example.com")
- assert result == {"service": "ok"}
-
-@patch("requests.get")
-def test_text_html_with_json(mock_get):
- mock_get.return_value = make_response(
- status=200,
- headers={"Content-Type": "text/html"},
- text='{"service": "ok"}'
- )
-
- result = _get_status_info("http://example.com")
- assert result == {"service": "ok"}
-
-@patch("requests.get")
-def test_text_plain_with_json(mock_get):
- mock_get.return_value = make_response(
- status=200,
- headers={"Content-Type": "text/plain"},
- text='{"a": 1}'
- )
-
- result = _get_status_info("http://example.com")
- assert result == {"a": 1}
-
-@patch("requests.get")
-def test_text_body_ok(mock_get):
- mock_get.return_value = make_response(
- status=200,
- headers={"Content-Type": "text/plain"},
- text='ok'
- )
-
- result = _get_status_info("http://example.com")
- assert result == {"text": "ok"}
-
-@patch("requests.get")
-def test_text_non_json_unrecognized(mock_get):
- mock_get.return_value = make_response(
- status=200,
- headers={"Content-Type": "text/plain"},
- text='Not JSON content'
- )
-
- result = _get_status_info("http://example.com")
- assert result == {
- "error": "Unexpected response text 'Not JSON content' for HTTP 200"
- }
-
-@patch("requests.get")
-def test_unsupported_content_type(mock_get):
- mock_get.return_value = make_response(
- status=200,
- headers={"Content-Type": "application/xml"},
- text=''
- )
-
- result = _get_status_info("http://example.com")
- assert result == {"error": "Unable to determine status from header content type"}
-
-@patch("requests.get")
-def test_connect_timeout(mock_get):
- mock_get.side_effect = requests.exceptions.ConnectTimeout()
-
- result = _get_status_info("http://example.com")
- assert result == {"connection_timeout": True, "read_timeout": None}
-
-@patch("requests.get")
-def test_read_timeout(mock_get):
- mock_get.side_effect = requests.exceptions.ReadTimeout()
-
- result = _get_status_info("http://example.com")
- assert result == {"connection_timeout": False, "read_timeout": True}
-
-@patch("requests.get")
-def test_generic_request_exception(mock_get):
- mock_get.side_effect = requests.exceptions.RequestException("Network issue")
-
- result = _get_status_info("http://example.com")
- assert result == {"error": "Network issue"}