From 1a44e8f1fd72b9a521c1fdc1a2d12a5d5b083f9a Mon Sep 17 00:00:00 2001 From: scrungus <33693738+scrungus@users.noreply.github.com> Date: Mon, 29 Jul 2024 13:55:49 +0100 Subject: [PATCH] Enable authentication (#9) * tokenauthentication * unit tests use auth * update functional test with token, https * run with gunicorn * let ingress controller enforce https * switch to bearer token --- Dockerfile | 5 +-- charts/templates/_helpers.tpl | 9 ++++- charts/templates/deployment.yaml | 33 +++++++++++++++-- charts/templates/django_superuser.yaml | 12 ++++++ charts/values.yaml | 6 +++ coral_credits/api/tests/allocation_tests.py | 2 +- coral_credits/api/tests/conftest.py | 15 +++++++- coral_credits/api/tests/consumer_tests.py | 2 + coral_credits/api/views.py | 15 ++++---- coral_credits/auth.py | 5 +++ coral_credits/test_settings.py | 4 ++ coral_credits/urls.py | 3 ++ etc/coral-credits/app.py | 4 ++ etc/gunicorn/conf.py | 12 ++++++ requirements.txt | 1 + tools/functional_test.sh | 41 ++++++++++++++------- 16 files changed, 136 insertions(+), 33 deletions(-) create mode 100644 charts/templates/django_superuser.yaml create mode 100644 coral_credits/auth.py create mode 100644 etc/gunicorn/conf.py diff --git a/Dockerfile b/Dockerfile index 96cb07b..1ac9b20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,12 +55,11 @@ ENV PYTHONUNBUFFERED 1 # Install application configuration using flexi-settings ENV DJANGO_SETTINGS_MODULE flexi_settings.settings ENV DJANGO_FLEXI_SETTINGS_ROOT /etc/coral-credits/settings.py -COPY ./etc/coral-credits /etc/coral-credits +COPY ./etc/ /etc/ RUN mkdir -p /etc/coral-credits/settings.d # By default, serve the app on port 8080 using the app user EXPOSE 8080 USER $APP_UID ENTRYPOINT ["tini", "-g", "--"] -#TODO(tylerchristie): use gunicorn + wsgi like azimuth -CMD ["python", "/coral-credits/manage.py", "runserver", "0.0.0.0:8080"] \ No newline at end of file +CMD ["/venv/bin/gunicorn", "--config", "/etc/gunicorn/conf.py", "coral_credits.wsgi:application"] \ No newline at end of file diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index 7d62d6c..d84ec6d 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -50,4 +50,11 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} {{ include "coral-credits.selectorLabels" . }} -{{- end }} \ No newline at end of file +{{- end }} + +{{/* +Secrets +*/}} +{{- define "coral-credits.djangoSecretName" -}} +{{- default (printf "%s-django-env" (include "coral-credits.fullname" .)) -}} +{{- end -}} \ No newline at end of file diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index ac833af..24d99cc 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -18,10 +18,31 @@ spec: initContainers: - name: migrate-db image: {{ printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) }} - command: - - python - - /coral-credits/manage.py - - migrate + env: + {{ with (include "coral-credits.djangoSecretName" . ) }} + - name: DJANGO_SUPERUSER_USERNAME + valueFrom: + secretKeyRef: + name: {{ quote . }} + key: username + - name: DJANGO_SUPERUSER_EMAIL + valueFrom: + secretKeyRef: + name: {{ quote . }} + key: email + - name: DJANGO_SUPERUSER_PASSWORD + valueFrom: + secretKeyRef: + name: {{ quote . }} + key: password + {{ end }} + command: ["/bin/sh"] + # Capture exit code from createsuperuser as it is not idempotent. + args: + - -c + - >- + python /coral-credits/manage.py migrate && + python /coral-credits/manage.py createsuperuser --no-input || echo $? volumeMounts: - name: data mountPath: /data @@ -53,6 +74,8 @@ spec: - name: runtime-settings mountPath: /etc/coral-credits/settings.d readOnly: true + - name: tmp + mountPath: /tmp {{- with .Values.nodeSelector }} nodeSelector: {{ toYaml . | nindent 8 }} {{- end }} @@ -69,3 +92,5 @@ spec: - name: runtime-settings secret: secretName: {{ include "coral-credits.fullname" . }} + - name: tmp + emptyDir: {} diff --git a/charts/templates/django_superuser.yaml b/charts/templates/django_superuser.yaml new file mode 100644 index 0000000..871e7a3 --- /dev/null +++ b/charts/templates/django_superuser.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "coral-credits.djangoSecretName" . }} + labels: {{ include "coral-credits.labels" . | nindent 4 }} +type: Opaque +# Use data because of https://github.com/helm/helm/issues/10010 +# Not doing so means that AWX-related keys are not removed on transition to the CRD +stringData: + password: {{ .Values.settings.superuserPassword | default (randAlphaNum 64) }} + username: {{ .Values.settings.superuserUsername | default "admin" }} + email: {{ .Values.settings.superuserEmail | default "admin@mail.com" }} \ No newline at end of file diff --git a/charts/values.yaml b/charts/values.yaml index 9b3756d..7fcd139 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -73,6 +73,12 @@ settings: # If not given, a randomly generated key will be used # However this will be different on each deployment which may cause sessions to be terminated secretKey: + # Same for the django superuser password + superuserPassword: + # Superuser username + superuserUsername: + # Superuser email + superuserEmail: # Use debug mode (recommended false in production) debug: false # Database settings diff --git a/coral_credits/api/tests/allocation_tests.py b/coral_credits/api/tests/allocation_tests.py index 1c82d01..cde02d2 100644 --- a/coral_credits/api/tests/allocation_tests.py +++ b/coral_credits/api/tests/allocation_tests.py @@ -26,7 +26,7 @@ def test_credit_allocation_resource_create_success( ) # Make the API call - response = api_client.post(url, request_data, format="json") + response = api_client.post(url, request_data, format="json", secure=True) # Check that the request was successful assert response.status_code == status.HTTP_200_OK, ( diff --git a/coral_credits/api/tests/conftest.py b/coral_credits/api/tests/conftest.py index 5cf69e2..454c7f8 100644 --- a/coral_credits/api/tests/conftest.py +++ b/coral_credits/api/tests/conftest.py @@ -1,7 +1,9 @@ from datetime import datetime, timedelta +from django.contrib.auth.models import User from django.utils.timezone import make_aware import pytest +from rest_framework.authtoken.models import Token from rest_framework.test import APIClient import coral_credits.api.models as models @@ -14,6 +16,13 @@ def pytest_configure(config): config.END_DATE = config.START_DATE + timedelta(days=1) +# Get auth token +@pytest.fixture +def token(): + user = User.objects.create_user(username="testuser", password="12345") + return Token.objects.create(user=user) + + # Fixtures defining all the necessary database entries for testing @pytest.fixture def resource_classes(): @@ -55,8 +64,10 @@ def credit_allocation(account, request): @pytest.fixture -def api_client(): - return APIClient() +def api_client(token): + client = APIClient() + client.credentials(HTTP_AUTHORIZATION="Bearer " + token.key) + return client @pytest.fixture diff --git a/coral_credits/api/tests/consumer_tests.py b/coral_credits/api/tests/consumer_tests.py index d80a76c..0841c22 100644 --- a/coral_credits/api/tests/consumer_tests.py +++ b/coral_credits/api/tests/consumer_tests.py @@ -71,6 +71,7 @@ def test_valid_create_request( url, data=json.dumps(request_data), content_type="application/json", + secure=True, ) assert response.status_code == status.HTTP_204_NO_CONTENT, ( @@ -139,6 +140,7 @@ def test_create_request_insufficient_credits( url, data=json.dumps(request_data), content_type="application/json", + secure=True, ) assert response.status_code == status.HTTP_403_FORBIDDEN, ( diff --git a/coral_credits/api/views.py b/coral_credits/api/views.py index 18a32c5..2112b0d 100644 --- a/coral_credits/api/views.py +++ b/coral_credits/api/views.py @@ -1,7 +1,7 @@ from django.db import transaction from django.db.utils import IntegrityError from django.shortcuts import get_object_or_404 -from rest_framework import status, viewsets +from rest_framework import permissions, status, viewsets from rest_framework.response import Response from coral_credits.api import db_exceptions, db_utils, models, serializers @@ -16,8 +16,7 @@ class CreditAllocationViewSet(viewsets.ModelViewSet): class CreditAllocationResourceViewSet(viewsets.ModelViewSet): queryset = models.CreditAllocationResource.objects.all() serializer_class = serializers.CreditAllocationResourceSerializer - # TODO(tylerchristie): enable authentication - # permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated] def create(self, request, allocation_pk=None): """Allocate credits to a dictionary of resource classes. @@ -66,25 +65,25 @@ def _validate_request(self, request): class ResourceClassViewSet(viewsets.ModelViewSet): queryset = models.ResourceClass.objects.all() serializer_class = serializers.ResourceClassSerializer - # permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated] class ResourceProviderViewSet(viewsets.ModelViewSet): queryset = models.ResourceProvider.objects.all() serializer_class = serializers.ResourceProviderSerializer - # permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated] class ResourceProviderAccountViewSet(viewsets.ModelViewSet): queryset = models.ResourceProviderAccount.objects.all() serializer_class = serializers.ResourceProviderAccountSerializer - # permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated] class AccountViewSet(viewsets.ModelViewSet): queryset = models.CreditAccount.objects.all() serializer_class = serializers.CreditAccountSerializer - # permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated] def retrieve(self, request, pk=None): """Retreives a Credit Account Summary""" @@ -134,7 +133,7 @@ def retrieve(self, request, pk=None): class ConsumerViewSet(viewsets.ModelViewSet): - # permission_classes = [permissions.IsAuthenticated] + permission_classes = [permissions.IsAuthenticated] def create(self, request): return self._create_or_update(request) diff --git a/coral_credits/auth.py b/coral_credits/auth.py new file mode 100644 index 0000000..f10889f --- /dev/null +++ b/coral_credits/auth.py @@ -0,0 +1,5 @@ +from rest_framework.authentication import TokenAuthentication + + +class BearerTokenAuthentication(TokenAuthentication): + keyword = "Bearer" diff --git a/coral_credits/test_settings.py b/coral_credits/test_settings.py index 6ae1c8b..efcde73 100644 --- a/coral_credits/test_settings.py +++ b/coral_credits/test_settings.py @@ -32,6 +32,7 @@ INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", + "rest_framework.authtoken", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", @@ -57,6 +58,9 @@ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_AUTHENTICATION_CLASSES": [ + "coral_credits.auth.BearerTokenAuthentication", + ], } ROOT_URLCONF = "coral_credits.urls" diff --git a/coral_credits/urls.py b/coral_credits/urls.py index 4986f08..4dc2103 100644 --- a/coral_credits/urls.py +++ b/coral_credits/urls.py @@ -18,6 +18,7 @@ from django.contrib import admin from django.http import HttpResponse from django.urls import include, path +from rest_framework.authtoken import views as drfviews from rest_framework_nested import routers from coral_credits.api import views @@ -51,4 +52,6 @@ def status(request): path("", include(allocation_router.urls)), path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), path("admin/", admin.site.urls), + # TODO(tylerchristie): probably need some permissions/scoping + path("api-token-auth/", drfviews.obtain_auth_token), ] diff --git a/etc/coral-credits/app.py b/etc/coral-credits/app.py index cfcb8da..975a509 100644 --- a/etc/coral-credits/app.py +++ b/etc/coral-credits/app.py @@ -22,6 +22,7 @@ INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", + "rest_framework.authtoken", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", @@ -47,6 +48,9 @@ REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_AUTHENTICATION_CLASSES": [ + "coral_credits.auth.BearerTokenAuthentication", + ], } ROOT_URLCONF = "coral_credits.urls" diff --git a/etc/gunicorn/conf.py b/etc/gunicorn/conf.py new file mode 100644 index 0000000..4c9335e --- /dev/null +++ b/etc/gunicorn/conf.py @@ -0,0 +1,12 @@ +# Default settings for gunicorn +# Also allows for overriding with environment variables +import os + +# Configure the bind address +_host = os.environ.get("GUNICORN_HOST", "0.0.0.0") +_port = os.environ.get("GUNICORN_PORT", "8080") +bind = os.environ.get("GUNICORN_BIND", "{}:{}".format(_host, _port)) + +# TODO(tylerchristie): configure workers and threads? + +# TODO(tylerchristie): configure logging diff --git a/requirements.txt b/requirements.txt index 8f1a470..1c0775b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ djangorestframework==3.15.2 drf-nested-routers==0.94.1 django-extensions==3.2.3 drf-spectacular==0.27.2 +gunicorn==22.0.0 tzdata==2024.1 \ No newline at end of file diff --git a/tools/functional_test.sh b/tools/functional_test.sh index 448b4d7..dfd5a84 100755 --- a/tools/functional_test.sh +++ b/tools/functional_test.sh @@ -27,6 +27,7 @@ check_http_status() { CHART_NAME="coral-credits" RELEASE_NAME=$CHART_NAME NAMESPACE=$CHART_NAME +TEST_PASSWORD="testpassword" # Install the CaaS operator from the chart we are about to ship # Make sure to use the images that we just built @@ -36,8 +37,9 @@ helm upgrade $RELEASE_NAME ./charts \ --create-namespace \ --install \ --wait \ - --timeout 10m \ - --set-string image.tag=${GITHUB_SHA::7} + --timeout 3m \ + --set-string image.tag=${GITHUB_SHA::7} \ + --set settings.superuserPassword=$TEST_PASSWORD # Wait for rollout kubectl rollout status deployment/$RELEASE_NAME -n $NAMESPACE --timeout=300s -w @@ -80,30 +82,41 @@ done echo "Running additional tests..." # Set up some variables -AUTH_HEADER="Authorization: Basic YWRtaW46cGFzc3dvcmQ=" # Base64 encoded "admin:password" CONTENT_TYPE="Content-Type: application/json" +# Get a token +echo "Getting an auth token:" +TOKEN=$(curl -s -X POST -H "$CONTENT_TYPE" -d \ + "{ + \"username\": \"admin\", + \"password\": \"$TEST_PASSWORD\" + }" \ + http://$SITE:$PORT/api-token-auth/ | jq -r '.token') +echo "Auth Token: $TOKEN" + +AUTH_HEADER="Authorization: Bearer $TOKEN" + # 1. Add a resource provider echo "Adding a resource provider:" -RESOURCE_PROVIDER_ID=$(curl -s -X POST -H "$CONTENT_TYPE" -d \ +RESOURCE_PROVIDER_ID=$(curl -s -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d \ '{ "name": "Test Provider", "email": "provider@test.com", - "info_url": "https://testprovider.com" + "info_url": "http://testprovider.com" }' \ http://$SITE:$PORT/resource_provider/ | jq -r '.url') echo "Resource Provider URL: $RESOURCE_PROVIDER_ID" # 2. Add resource classes echo "Adding resource classes:" -VCPU_ID=$(curl -s -X POST -H "$CONTENT_TYPE" -d '{"name": "VCPU"}' http://$SITE:$PORT/resource_class/ | jq -r '.id') -MEMORY_ID=$(curl -s -X POST -H "$CONTENT_TYPE" -d '{"name": "MEMORY_MB"}' http://$SITE:$PORT/resource_class/ | jq -r '.id') -DISK_ID=$(curl -s -X POST -H "$CONTENT_TYPE" -d '{"name": "DISK_GB"}' http://$SITE:$PORT/resource_class/ | jq -r '.id') +VCPU_ID=$(curl -s -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d '{"name": "VCPU"}' http://$SITE:$PORT/resource_class/ | jq -r '.id') +MEMORY_ID=$(curl -s -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d '{"name": "MEMORY_MB"}' http://$SITE:$PORT/resource_class/ | jq -r '.id') +DISK_ID=$(curl -s -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d '{"name": "DISK_GB"}' http://$SITE:$PORT/resource_class/ | jq -r '.id') echo "Resource Class IDs: VCPU=$VCPU_ID, MEMORY_MB=$MEMORY_ID, DISK_GB=$DISK_ID" # 3. Add an account echo "Adding an account:" -ACCOUNT_ID=$(curl -s -X POST -H "$CONTENT_TYPE" -d \ +ACCOUNT_ID=$(curl -s -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d \ '{ "name": "Test Account", "email": "test@account.com" @@ -114,7 +127,7 @@ echo "Account URL: $ACCOUNT_ID" PROJECT_ID="20354d7a-e4fe-47af-8ff6-187bca92f3f9" # 4. Add a resource provider account echo "Adding a resource provider account:" -RPA_ID=$(curl -s -X POST -H "$CONTENT_TYPE" -d \ +RPA_ID=$(curl -s -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d \ "{ \"account\": \"$ACCOUNT_ID\", \"provider\": \"$RESOURCE_PROVIDER_ID\", @@ -127,7 +140,7 @@ START_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") END_DATE=$(date -u -d "+1 day" +"%Y-%m-%dT%H:%M:%SZ") # 5. Add some credit allocation echo "Adding credit allocation:" -ALLOCATION_ID=$(curl -s -X POST -H "$CONTENT_TYPE" -d \ +ALLOCATION_ID=$(curl -s -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d \ "{ \"name\": \"Test Allocation\", \"account\": \"$ACCOUNT_ID\", @@ -139,7 +152,7 @@ echo "Credit Allocation ID: $ALLOCATION_ID" # 6. Add allocation to resource echo "Adding allocation to resources:" -curl -s -X POST -H "$CONTENT_TYPE" -d \ +curl -s -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d \ "{ \"inventories\": { \"VCPU\": 100, @@ -151,11 +164,11 @@ curl -s -X POST -H "$CONTENT_TYPE" -d \ # 7. Do a consumer create echo "Creating a consumer:" -RESPONSE=$(curl -s -w "%{http_code}" -X POST -H "$CONTENT_TYPE" -d "{ +RESPONSE=$(curl -s -w "%{http_code}" -X POST -H "$AUTH_HEADER" -H "$CONTENT_TYPE" -d "{ \"context\": { \"user_id\": \"caa8b54a-eb5e-4134-8ae2-a3946a428ec7\", \"project_id\": \"$PROJECT_ID\", - \"auth_url\": \"https://api.example.com:5000/v3\", + \"auth_url\": \"http://api.example.com:5000/v3\", \"region_name\": \"RegionOne\" }, \"lease\": {