From 7d506aa71b3014970ffc8352c1430e7bd8f61a38 Mon Sep 17 00:00:00 2001 From: Mial Lewis Date: Thu, 29 Jan 2026 14:16:23 +0000 Subject: [PATCH 1/7] add_query_endpoint --- blank.env | 3 +++ docker-compose.yml | 9 --------- web/services/urls.py | 1 + web/services/views.py | 27 +++++++++++++++++++++++++++ web/settings.py | 11 +++++++++++ 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/blank.env b/blank.env index df82549..0f8f6aa 100644 --- a/blank.env +++ b/blank.env @@ -17,6 +17,9 @@ DB_PORT=5432 SECRET_KEY= DB_USER= DB_PASS= +DB_RO_USER= +DB_RO_PASS= +QUERY_SECRET_KEY= #Trusted origins for CSRF validation. For production usage only specify https://reports.mantidproject.org DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8082,https://reports.a.staging-mantidproject.stfc.ac.uk diff --git a/docker-compose.yml b/docker-compose.yml index af74954..7630158 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,6 @@ services: POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASS} POSTGRES_DB: ${DB_NAME} - DB_SERVICE: ${DB_SERVICE} - DB_PORT: ${DB_PORT} - SECRET_KEY: ${SECRET_KEY} adminer: image: adminer @@ -34,12 +31,6 @@ services: depends_on: - postgres env_file: .env - environment: - DB_SERVICE: ${DB_SERVICE} - DB_PORT: ${DB_PORT} - SECRET_KEY: ${SECRET_KEY} - # Define this in .env for development mode. DO NOT USE IN PRODUCTION - DEBUG: ${DEBUG} nginx-reports: restart: always diff --git a/web/services/urls.py b/web/services/urls.py index 66cfb40..c7075c9 100644 --- a/web/services/urls.py +++ b/web/services/urls.py @@ -17,6 +17,7 @@ path("by/user", views.usage_by_users, name="by-users"), path("host", views.host_list, name="host-list"), path("user", views.user_list, name="user-list"), + path("query", views.query, name="query"), # url(r'feature', views.feature_usage, name='feature_usage'), ] diff --git a/web/services/views.py b/web/services/views.py index ef8e81e..d11d6aa 100644 --- a/web/services/views.py +++ b/web/services/views.py @@ -15,10 +15,13 @@ import django_filters from rest_framework.reverse import reverse from django.http import HttpResponse +from django.db import connections + import json import datetime import hashlib import services.plots as plotsfile +from os import environ OS_NAMES = ["Linux", "Windows NT", "Darwin"] UTC = datetime.tzinfo("UTC") @@ -327,6 +330,30 @@ def by_root(request, format=None): } ) +@api_view(("POST",)) +def query(request, format=None): + sql_err, sql = get_parameter(request, "sql") + token_err, token = get_parameter(request, "token") + if sql_err or token_err: + return response.Response(status=400, data=f"Invalid Parameters: {[x for x in [sql_err, token_err] if x]}") + verified = verify_token(token) + if not verified: + return response.Response(status=401, data="UNAUTHORIZED") + conn=connections["readonly"] + with conn.cursor() as cur: + cur.execute(sql) + res = cur.fetchall() + return response.Response(res) + +def get_parameter(request, param): + val = request.POST.get[param] + err = "" + if not val: + err = f"No {param} parameter provided" + return err, val + +def verify_token(token): + return token==environ["QUERY_SECRET_KEY"] class FeatureViewSet(viewsets.ModelViewSet): """ diff --git a/web/settings.py b/web/settings.py index a5b2213..f826223 100644 --- a/web/settings.py +++ b/web/settings.py @@ -94,6 +94,17 @@ "PASSWORD": os.environ["DB_PASS"], "HOST": os.environ["DB_SERVICE"], "PORT": os.environ["DB_PORT"], + }, + "readonly": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": os.environ["DB_NAME"], + "USER": os.environ["DB_RO_USER"], + "PASSWORD": os.environ["DB_RO_PASS"], + "HOST": os.environ["DB_SERVICE"], + "PORT": os.environ["DB_PORT"], + "OPTIONS": { + "options": "-c default_transaction_read_only=on", + }, } } From 109716646b3d9208f3ee171ba86cb7c15056af7e Mon Sep 17 00:00:00 2001 From: Mial Lewis Date: Thu, 29 Jan 2026 16:25:38 +0000 Subject: [PATCH 2/7] add reload to debug --- web/run_django.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/run_django.sh b/web/run_django.sh index 5a52ae3..b36ddb8 100755 --- a/web/run_django.sh +++ b/web/run_django.sh @@ -8,7 +8,7 @@ python manage.py migrate --noinput # If running in DEBUG mode add debug logging to gunicorn if [ -n "${DEBUG}" ]; then - DEBUG_ARGS="--log-level debug --capture-output" + DEBUG_ARGS="--log-level debug --capture-output --reload" else DEBUG_ARGS= fi From 6d4560278767654b7fc7586409fb95ebfea9e9b7 Mon Sep 17 00:00:00 2001 From: Mial Lewis Date: Thu, 29 Jan 2026 16:59:19 +0000 Subject: [PATCH 3/7] correct typo --- web/services/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/services/views.py b/web/services/views.py index d11d6aa..0704f45 100644 --- a/web/services/views.py +++ b/web/services/views.py @@ -346,7 +346,7 @@ def query(request, format=None): return response.Response(res) def get_parameter(request, param): - val = request.POST.get[param] + val = request.POST.get(param) err = "" if not val: err = f"No {param} parameter provided" From 21056789d5700b642fe5eab1e81bd1300c684e87 Mon Sep 17 00:00:00 2001 From: Mial Lewis Date: Fri, 30 Jan 2026 14:44:58 +0000 Subject: [PATCH 4/7] improve security first pass --- nginx/confs/mantidreports.conf | 18 ++++++++++++++++++ web/services/views.py | 29 +++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/nginx/confs/mantidreports.conf b/nginx/confs/mantidreports.conf index ef494e9..9165235 100644 --- a/nginx/confs/mantidreports.conf +++ b/nginx/confs/mantidreports.conf @@ -32,4 +32,22 @@ server { proxy_read_timeout 60s; } + location /api/query { + # allow ISIS VPN traffic + allow 130.246.180.13/32; + allow 130.246.180.101/32; + allow 130.246.186.163/32; + allow 130.246.223.126/32; + deny all; + + proxy_pass http://web:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } } diff --git a/web/services/views.py b/web/services/views.py index 0704f45..19f918e 100644 --- a/web/services/views.py +++ b/web/services/views.py @@ -22,6 +22,7 @@ import hashlib import services.plots as plotsfile from os import environ +import re OS_NAMES = ["Linux", "Windows NT", "Darwin"] UTC = datetime.tzinfo("UTC") @@ -332,19 +333,29 @@ def by_root(request, format=None): @api_view(("POST",)) def query(request, format=None): - sql_err, sql = get_parameter(request, "sql") - token_err, token = get_parameter(request, "token") - if sql_err or token_err: - return response.Response(status=400, data=f"Invalid Parameters: {[x for x in [sql_err, token_err] if x]}") - verified = verify_token(token) - if not verified: + if not verify_token(request): return response.Response(status=401, data="UNAUTHORIZED") + sql_err, sql = get_parameter(request, "sql") + if sql_err: + return response.Response(status=400, data=f"Invalid Parameters: {sql_err}") conn=connections["readonly"] with conn.cursor() as cur: cur.execute(sql) res = cur.fetchall() return response.Response(res) +def get_bearer_token(request): + """ + Expect: Authorization: Bearer + """ + auth = request.headers.get("Authorization", "") + if not auth: + return None + parts = auth.split(None, 1) # ["Bearer", ""] + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + return parts[1].strip() or None + def get_parameter(request, param): val = request.POST.get(param) err = "" @@ -352,8 +363,10 @@ def get_parameter(request, param): err = f"No {param} parameter provided" return err, val -def verify_token(token): - return token==environ["QUERY_SECRET_KEY"] +def verify_token(request) -> bool: + token = get_bearer_token(request) + secret = environ.get("QUERY_SECRET_KEY", "") + return token==secret class FeatureViewSet(viewsets.ModelViewSet): """ From 719ed0c41b2da4e27554a8c37a640d3e06fc9756 Mon Sep 17 00:00:00 2001 From: Mial Lewis Date: Fri, 30 Jan 2026 14:50:45 +0000 Subject: [PATCH 5/7] udpdate IP range --- nginx/confs/mantidreports.conf | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/nginx/confs/mantidreports.conf b/nginx/confs/mantidreports.conf index 9165235..a94776a 100644 --- a/nginx/confs/mantidreports.conf +++ b/nginx/confs/mantidreports.conf @@ -34,10 +34,7 @@ server { location /api/query { # allow ISIS VPN traffic - allow 130.246.180.13/32; - allow 130.246.180.101/32; - allow 130.246.186.163/32; - allow 130.246.223.126/32; + allow 130.246.0.0/16; deny all; proxy_pass http://web:8000; From 4bb05c5e438dc8fb7307e8f7a13db1f6d61f30ba Mon Sep 17 00:00:00 2001 From: Mial Lewis Date: Fri, 30 Jan 2026 16:05:51 +0000 Subject: [PATCH 6/7] add realip to cong --- nginx/confs/mantidreports.conf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nginx/confs/mantidreports.conf b/nginx/confs/mantidreports.conf index a94776a..ce9c645 100644 --- a/nginx/confs/mantidreports.conf +++ b/nginx/confs/mantidreports.conf @@ -32,6 +32,10 @@ server { proxy_read_timeout 60s; } + set_real_ip_from 172.0.17.0/24; + real_ip_header X-Forwarded-For; + real_ip_recursive on; + location /api/query { # allow ISIS VPN traffic allow 130.246.0.0/16; From 752374185825751639801c1d5d4d1fc39b03de49 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 30 Jan 2026 16:08:59 +0000 Subject: [PATCH 7/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- web/services/views.py | 10 +++++++--- web/settings.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/web/services/views.py b/web/services/views.py index 19f918e..5fa75d6 100644 --- a/web/services/views.py +++ b/web/services/views.py @@ -22,7 +22,6 @@ import hashlib import services.plots as plotsfile from os import environ -import re OS_NAMES = ["Linux", "Windows NT", "Darwin"] UTC = datetime.tzinfo("UTC") @@ -331,6 +330,7 @@ def by_root(request, format=None): } ) + @api_view(("POST",)) def query(request, format=None): if not verify_token(request): @@ -338,12 +338,13 @@ def query(request, format=None): sql_err, sql = get_parameter(request, "sql") if sql_err: return response.Response(status=400, data=f"Invalid Parameters: {sql_err}") - conn=connections["readonly"] + conn = connections["readonly"] with conn.cursor() as cur: cur.execute(sql) res = cur.fetchall() return response.Response(res) + def get_bearer_token(request): """ Expect: Authorization: Bearer @@ -356,6 +357,7 @@ def get_bearer_token(request): return None return parts[1].strip() or None + def get_parameter(request, param): val = request.POST.get(param) err = "" @@ -363,10 +365,12 @@ def get_parameter(request, param): err = f"No {param} parameter provided" return err, val + def verify_token(request) -> bool: token = get_bearer_token(request) secret = environ.get("QUERY_SECRET_KEY", "") - return token==secret + return token == secret + class FeatureViewSet(viewsets.ModelViewSet): """ diff --git a/web/settings.py b/web/settings.py index f826223..34d7bab 100644 --- a/web/settings.py +++ b/web/settings.py @@ -105,7 +105,7 @@ "OPTIONS": { "options": "-c default_transaction_read_only=on", }, - } + }, } # Internationalization