Skip to content

Commit 9da3d4d

Browse files
Merge pull request #97 from mantidproject/add_query_endpoint
Add query endpoint for trusted users
2 parents dde5bff + 7bbb564 commit 9da3d4d

7 files changed

Lines changed: 105 additions & 12 deletions

File tree

blank.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ DB_PORT=5432
1717
SECRET_KEY=<Not Set>
1818
DB_USER=<Not Set>
1919
DB_PASS=<Not Set>
20+
DB_RO_USER=<Not Set>
21+
DB_RO_PASS=<Not Set>
22+
QUERY_SECRET_KEY=<Not Set>
2023

2124
#Trusted origins for CSRF validation. For production usage only specify https://reports.mantidproject.org
2225
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8082,https://reports.a.staging-mantidproject.stfc.ac.uk

docker-compose.yml

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ services:
99
POSTGRES_USER: ${DB_USER}
1010
POSTGRES_PASSWORD: ${DB_PASS}
1111
POSTGRES_DB: ${DB_NAME}
12-
DB_SERVICE: ${DB_SERVICE}
13-
DB_PORT: ${DB_PORT}
14-
SECRET_KEY: ${SECRET_KEY}
1512

1613
adminer:
1714
image: adminer
@@ -33,12 +30,6 @@ services:
3330
depends_on:
3431
- postgres
3532
env_file: .env
36-
environment:
37-
DB_SERVICE: ${DB_SERVICE}
38-
DB_PORT: ${DB_PORT}
39-
SECRET_KEY: ${SECRET_KEY}
40-
# Define this in .env for development mode. DO NOT USE IN PRODUCTION
41-
DEBUG: ${DEBUG}
4233

4334
nginx-reports:
4435
restart: always

nginx/confs/reports_server.conf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,19 @@ server {
3232
proxy_read_timeout 60s;
3333
}
3434

35+
location /api/query {
36+
# allow ISIS VPN traffic
37+
allow <ISIS_VPN_IP_RANGE>;
38+
deny all;
39+
40+
proxy_pass http://web:8000;
41+
proxy_set_header Host $host;
42+
proxy_set_header X-Real-IP $remote_addr;
43+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
44+
proxy_set_header X-Forwarded-Proto $scheme;
45+
46+
proxy_connect_timeout 60s;
47+
proxy_send_timeout 60s;
48+
proxy_read_timeout 60s;
49+
}
3550
}

web/run_django.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ python manage.py migrate --noinput
88

99
# If running in DEBUG mode add debug logging to gunicorn
1010
if [ -n "${DEBUG}" ]; then
11-
DEBUG_ARGS="--log-level debug --capture-output"
11+
DEBUG_ARGS="--log-level debug --capture-output --reload"
1212
else
1313
DEBUG_ARGS=
1414
fi

web/services/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
path("by/user", views.usage_by_users, name="by-users"),
1818
path("host", views.host_list, name="host-list"),
1919
path("user", views.user_list, name="user-list"),
20+
path("query", views.query, name="query"),
2021
# url(r'feature', views.feature_usage, name='feature_usage'),
2122
]
2223

web/services/views.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Create your views here.
44
from django.views.decorators.cache import cache_page
55
from services.models import Message, Usage, FeatureUsage, Location
6-
from rest_framework import response, viewsets
6+
from rest_framework import response, viewsets, status
77
from rest_framework.decorators import api_view
88
from rest_framework.permissions import IsAuthenticatedOrReadOnly, AllowAny
99
from services.serializer import (
@@ -15,10 +15,17 @@
1515
import django_filters
1616
from rest_framework.reverse import reverse
1717
from django.http import HttpResponse
18+
from django.db import connections
19+
1820
import json
1921
import datetime
2022
import hashlib
2123
import services.plots as plotsfile
24+
from os import environ
25+
from hmac import compare_digest
26+
import logging
27+
28+
logger = logging.getLogger(__name__)
2229

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

330337

338+
@api_view(("POST",))
339+
def query(request, format=None):
340+
if not verify_token(request):
341+
logger.warning("Unauthorized query attempt")
342+
return response.Response(
343+
status=status.HTTP_401_UNAUTHORIZED, data="UNAUTHORIZED"
344+
)
345+
346+
param_err, sql = get_parameter(request, "sql")
347+
if param_err:
348+
logger.warning(f"Invalid query parameters: {param_err}")
349+
return response.Response(
350+
status=status.HTTP_400_BAD_REQUEST, data=f"Invalid Parameters: {param_err}"
351+
)
352+
353+
if not sql:
354+
logger.warning("No sql parameter provided")
355+
return response.Response(
356+
status=status.HTTP_400_BAD_REQUEST, data="No sql parameter provided"
357+
)
358+
359+
try:
360+
conn = connections["readonly"]
361+
with conn.cursor() as cur:
362+
cur.execute(sql)
363+
res = cur.fetchall()
364+
return response.Response(res)
365+
except Exception:
366+
logger.exception("Query execution failed")
367+
return response.Response(
368+
{"error": "Query failed"}, status=status.HTTP_400_BAD_REQUEST
369+
)
370+
371+
372+
def get_bearer_token(request):
373+
"""
374+
Expect: Authorization: Bearer <token>
375+
"""
376+
auth = request.headers.get("Authorization", "")
377+
if not auth:
378+
logger.warning("No Authorization header provided")
379+
return None
380+
parts = auth.split(None, 1) # ["Bearer", "<token>"]
381+
if len(parts) != 2 or parts[0].lower() != "bearer":
382+
logger.warning("Invalid Authorization header format")
383+
return None
384+
return parts[1].strip() or None
385+
386+
387+
def get_parameter(request, param):
388+
val = request.POST.get(param)
389+
if val is None or val.strip() == "":
390+
return f"No {param} parameter provided", None
391+
return None, val
392+
393+
394+
def verify_token(request) -> bool:
395+
token = get_bearer_token(request)
396+
secret = environ.get("QUERY_SECRET_KEY", "")
397+
if not token or not secret:
398+
logger.warning("Missing token or secret")
399+
return False
400+
return compare_digest(token, secret)
401+
402+
331403
class FeatureViewSet(viewsets.ModelViewSet):
332404
"""
333405
A viewset that provides the standard actions,

web/settings.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,18 @@
9494
"PASSWORD": os.environ["DB_PASS"],
9595
"HOST": os.environ["DB_SERVICE"],
9696
"PORT": os.environ["DB_PORT"],
97-
}
97+
},
98+
"readonly": {
99+
"ENGINE": "django.db.backends.postgresql_psycopg2",
100+
"NAME": os.environ["DB_NAME"],
101+
"USER": os.environ["DB_RO_USER"],
102+
"PASSWORD": os.environ["DB_RO_PASS"],
103+
"HOST": os.environ["DB_SERVICE"],
104+
"PORT": os.environ["DB_PORT"],
105+
"OPTIONS": {
106+
"options": "-c default_transaction_read_only=on",
107+
},
108+
},
98109
}
99110

100111
# Internationalization

0 commit comments

Comments
 (0)