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..80bec7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.2' services: postgres: image: postgres:11.5 @@ -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 @@ -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 @@ -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 diff --git a/nginx/confs/mantidreports.conf b/nginx/confs/reports_server.conf similarity index 68% rename from nginx/confs/mantidreports.conf rename to nginx/confs/reports_server.conf index ef494e9..2494d23 100644 --- a/nginx/confs/mantidreports.conf +++ b/nginx/confs/reports_server.conf @@ -32,4 +32,19 @@ server { proxy_read_timeout 60s; } + location /api/query { + # allow ISIS VPN traffic + allow ; + 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/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 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 52b0676..80a2111 100644 --- a/web/services/views.py +++ b/web/services/views.py @@ -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 ( @@ -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") @@ -328,6 +335,71 @@ def by_root(request, format=None): ) +@api_view(("POST",)) +def query(request, format=None): + 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 + """ + auth = request.headers.get("Authorization", "") + if not auth: + logger.warning("No Authorization header provided") + return None + parts = auth.split(None, 1) # ["Bearer", ""] + 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, diff --git a/web/settings.py b/web/settings.py index a5b2213..34d7bab 100644 --- a/web/settings.py +++ b/web/settings.py @@ -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