Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7d506aa
add_query_endpoint
MialLewis Jan 29, 2026
1097166
add reload to debug
MialLewis Jan 29, 2026
6d45602
correct typo
MialLewis Jan 29, 2026
2105678
improve security first pass
MialLewis Jan 30, 2026
719ed0c
udpdate IP range
MialLewis Jan 30, 2026
4bb05c5
add realip to cong
MialLewis Jan 30, 2026
7523741
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 30, 2026
f049303
renamed server config file
warunawickramasingha Jun 12, 2026
dde5bff
Merge pull request #98 from mantidproject/server_allowlisting
warunawickramasingha Jul 17, 2026
46b086c
add_query_endpoint
MialLewis Jan 29, 2026
430b52e
add reload to debug
MialLewis Jan 29, 2026
d6f45a2
correct typo
MialLewis Jan 29, 2026
a0e0d56
improve security first pass
MialLewis Jan 30, 2026
5399c6c
udpdate IP range
MialLewis Jan 30, 2026
3adeb69
add realip to cong
MialLewis Jan 30, 2026
051353a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 30, 2026
6767f12
rebased to main and to be tested
warunawickramasingha Jul 17, 2026
1811176
resolved conflicts
warunawickramasingha Jul 17, 2026
a7948ea
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 17, 2026
0880502
added logs
warunawickramasingha Jul 20, 2026
96974d5
Merge branch 'add_query_endpoint' of https://github.com/mantidproject…
warunawickramasingha Jul 20, 2026
3643dc8
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 20, 2026
2c1b1d4
logs updated
warunawickramasingha Jul 20, 2026
7bbb564
Merge branch 'add_query_endpoint' of https://github.com/mantidproject…
warunawickramasingha Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions blank.env
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ DB_PORT=5432
SECRET_KEY=<Not Set>
DB_USER=<Not Set>
DB_PASS=<Not Set>
DB_RO_USER=<Not Set>
DB_RO_PASS=<Not Set>
QUERY_SECRET_KEY=<Not Set>

#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
12 changes: 1 addition & 11 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
version: '3.2'
services:
postgres:
image: postgres:11.5
Expand All @@ -10,9 +9,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
Expand All @@ -34,12 +30,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
Expand All @@ -49,7 +39,7 @@ services:
- "${HOST_PORT}:80"
volumes:
- webdata:/usr/src/app
- "./nginx/confs/mantidreports.conf:/opt/bitnami/nginx/conf/server_blocks/mantidreports.conf:ro"
- "./nginx/confs/reports_server.conf:/opt/bitnami/nginx/conf/server_blocks/reports_server.conf:ro"
networks:
- default
- nginx_net
Expand Down
15 changes: 15 additions & 0 deletions nginx/confs/mantidreports.conf → nginx/confs/reports_server.conf
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,19 @@ server {
proxy_read_timeout 60s;
}

location /api/query {
# allow ISIS VPN traffic
allow <ISIS_VPN_IP_RANGE>;
deny all;

Comment thread
warunawickramasingha marked this conversation as resolved.
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;
Comment thread
warunawickramasingha marked this conversation as resolved.
}
}
Comment thread
warunawickramasingha marked this conversation as resolved.
2 changes: 1 addition & 1 deletion web/run_django.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions web/services/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]

Expand Down
74 changes: 73 additions & 1 deletion web/services/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Create your views here.
from django.views.decorators.cache import cache_page
from services.models import Message, Usage, FeatureUsage, Location
from rest_framework import response, viewsets
from rest_framework import response, viewsets, status
from rest_framework.decorators import api_view
from rest_framework.permissions import IsAuthenticatedOrReadOnly, AllowAny
from services.serializer import (
Expand All @@ -15,10 +15,17 @@
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
from hmac import compare_digest
import logging

logger = logging.getLogger(__name__)

OS_NAMES = ["Linux", "Windows NT", "Darwin"]
UTC = datetime.tzinfo("UTC")
Expand Down Expand Up @@ -328,6 +335,71 @@ def by_root(request, format=None):
)


@api_view(("POST",))
def query(request, format=None):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add an if statement for safeguarding the SQL command. Only SELECT queries are needed, anything else (DROP, DELETE, ALTER...) are unnecessary so they could just get ignored. Even with the restriction of trusted users there could be an innocent mistake.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already protected by the DB due to the read only user account associated with the queries who can only submit select queries. That extra layer of validation can be added to the Django's web service level to free up the DB from it. But still the effect would be equivalent I think.

if not verify_token(request):
logger.warning("Unauthorized query attempt")
return response.Response(
status=status.HTTP_401_UNAUTHORIZED, data="UNAUTHORIZED"
)

param_err, sql = get_parameter(request, "sql")
if param_err:
logger.warning(f"Invalid query parameters: {param_err}")
return response.Response(
status=status.HTTP_400_BAD_REQUEST, data=f"Invalid Parameters: {param_err}"
)

if not sql:
logger.warning("No sql parameter provided")
return response.Response(
status=status.HTTP_400_BAD_REQUEST, data="No sql parameter provided"
)

try:
conn = connections["readonly"]
with conn.cursor() as cur:
cur.execute(sql)
res = cur.fetchall()
return response.Response(res)
except Exception:
logger.exception("Query execution failed")
return response.Response(
{"error": "Query failed"}, status=status.HTTP_400_BAD_REQUEST
)


def get_bearer_token(request):
"""
Expect: Authorization: Bearer <token>
"""
auth = request.headers.get("Authorization", "")
if not auth:
logger.warning("No Authorization header provided")
return None
parts = auth.split(None, 1) # ["Bearer", "<token>"]
if len(parts) != 2 or parts[0].lower() != "bearer":
logger.warning("Invalid Authorization header format")
return None
return parts[1].strip() or None


def get_parameter(request, param):
val = request.POST.get(param)
if val is None or val.strip() == "":
return f"No {param} parameter provided", None
return None, val


def verify_token(request) -> bool:
token = get_bearer_token(request)
secret = environ.get("QUERY_SECRET_KEY", "")
if not token or not secret:
logger.warning("Missing token or secret")
return False
return compare_digest(token, secret)


class FeatureViewSet(viewsets.ModelViewSet):
"""
A viewset that provides the standard actions,
Expand Down
13 changes: 12 additions & 1 deletion web/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,18 @@
"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",
},
},
}

# Internationalization
Expand Down
Loading