From 195fa28dfcf63dcd1d2418ad2e99125933141aab Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 26 Mar 2026 16:08:58 +0100 Subject: [PATCH 1/3] Add authentication configuration for django-restframework and adapt tests for auth --- .../haproxy_route_policy/settings.py | 12 +++ .../haproxy_route_policy/test_settings.py | 7 ++ .../test_settings_authenticated.py | 16 ++++ .../policy/tests/test_auth.py | 85 +++++++++++++++++++ haproxy-route-policy/pyproject.toml | 4 + haproxy-route-policy/tox.toml | 20 ++++- haproxy-route-policy/uv.lock | 29 +++++++ 7 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 haproxy-route-policy/haproxy_route_policy/test_settings_authenticated.py create mode 100644 haproxy-route-policy/policy/tests/test_auth.py diff --git a/haproxy-route-policy/haproxy_route_policy/settings.py b/haproxy-route-policy/haproxy_route_policy/settings.py index 7c3d2c34..0ef69592 100644 --- a/haproxy-route-policy/haproxy_route_policy/settings.py +++ b/haproxy-route-policy/haproxy_route_policy/settings.py @@ -124,6 +124,18 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +# django rest framework options +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.BasicAuthentication", + "rest_framework.authentication.SessionAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], +} + env_log_level = os.getenv("DJANGO_LOG_LEVEL", "INFO").upper() if env_log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: env_log_level = "INFO" diff --git a/haproxy-route-policy/haproxy_route_policy/test_settings.py b/haproxy-route-policy/haproxy_route_policy/test_settings.py index dc1db7f2..91c46a6c 100644 --- a/haproxy-route-policy/haproxy_route_policy/test_settings.py +++ b/haproxy-route-policy/haproxy_route_policy/test_settings.py @@ -14,3 +14,10 @@ "NAME": ":memory:", } } + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.AllowAny", + ], +} diff --git a/haproxy-route-policy/haproxy_route_policy/test_settings_authenticated.py b/haproxy-route-policy/haproxy_route_policy/test_settings_authenticated.py new file mode 100644 index 00000000..c3090bfc --- /dev/null +++ b/haproxy-route-policy/haproxy_route_policy/test_settings_authenticated.py @@ -0,0 +1,16 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Django settings for running tests with SQLite in an authenticated setup.""" + +from haproxy_route_policy.settings import * # noqa: F401, F403 + +# Mock secret key for testing. +SECRET_KEY = "test-secret-key" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} diff --git a/haproxy-route-policy/policy/tests/test_auth.py b/haproxy-route-policy/policy/tests/test_auth.py new file mode 100644 index 00000000..d91bbc1c --- /dev/null +++ b/haproxy-route-policy/policy/tests/test_auth.py @@ -0,0 +1,85 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Authentication tests.""" + +from django.test import TestCase, tag +from rest_framework.test import APIClient +from django.contrib.auth.models import User + + +@tag("auth") +class TestAuthenticationRequired(TestCase): + """Tests that endpoints require authentication.""" + + def setUp(self): + self.client = APIClient() + + def test_list_requests_unauthenticated(self): + """GET /api/v1/requests returns 401/403 without auth.""" + response = self.client.get("/api/v1/requests") + self.assertIn(response.status_code, [401, 403]) + + def test_create_requests_unauthenticated(self): + """POST /api/v1/requests returns 401/403 without auth.""" + response = self.client.post("/api/v1/requests", [], format="json") + self.assertIn(response.status_code, [401, 403]) + + def test_list_rules_unauthenticated(self): + """GET /api/v1/rules returns 401/403 without auth.""" + response = self.client.get("/api/v1/rules") + self.assertIn(response.status_code, [401, 403]) + + +@tag("auth") +class TestAuthenticated(TestCase): + """Tests endpoints as an authenticated user.""" + + def setUp(self): + self.user = User.objects.create_user("admin", "admin@example.com", "admin") + self.client = APIClient() + # Add nosec to ignore bandit warning as this is for testing. + self.client.login(username="admin", password="admin") # nosec + + def test_create_requests_authenticated(self): + """POST /api/v1/requests returns 201 with auth.""" + payload = [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "paths": ["/api"], + "port": 443, + }, + { + "relation_id": 2, + "backend_name": "backend-2", + "port": 443, + }, + ] + response = self.client.post("/api/v1/requests", data=payload, format="json") + self.assertEqual(response.status_code, 201) + + def test_create_rules_authenticated(self): + """POST /api/v1/rules returns 201 with auth.""" + payload = { + "name": "Test Rule", + "action": "allow", + "kind": "hostname_and_path_match", + "parameters": { + "hostnames": ["example.com"], + "paths": ["/api"], + }, + } + response = self.client.post("/api/v1/rules", data=payload, format="json") + self.assertEqual(response.status_code, 201) + + def test_list_requests_authenticated(self): + """GET /api/v1/requests returns 200 with auth.""" + response = self.client.get("/api/v1/requests") + self.assertEqual(response.status_code, 200) + + def test_list_rules_authenticated(self): + """GET /api/v1/rules returns 200 with auth.""" + response = self.client.get("/api/v1/rules") + self.assertEqual(response.status_code, 200) diff --git a/haproxy-route-policy/pyproject.toml b/haproxy-route-policy/pyproject.toml index e1443b55..ad337e6a 100644 --- a/haproxy-route-policy/pyproject.toml +++ b/haproxy-route-policy/pyproject.toml @@ -7,11 +7,15 @@ requires-python = ">=3.12" dependencies = [ "django>=6.0.3", "djangorestframework>=3.16.1", + "djangorestframework-simplejwt>=5.5.1", "validators>=0.35.0", "whitenoise>=6.12.0", ] [dependency-groups] +auth = [ + "djangorestframework-simplejwt>=5.5.1", +] coverage-report = [ "coverage[toml]>=7.13.5", ] diff --git a/haproxy-route-policy/tox.toml b/haproxy-route-policy/tox.toml index 40fa551c..3923feee 100644 --- a/haproxy-route-policy/tox.toml +++ b/haproxy-route-policy/tox.toml @@ -5,7 +5,7 @@ skipsdist = true skip_missing_interpreters = true requires = ["tox>=4.21"] no_package = true -envlist = ["lint", "unit", "static", "coverage-report"] +envlist = ["lint", "unit", "unit-auth", "static", "coverage-report"] [env_run_base] passenv = ["PYTHONPATH"] @@ -26,11 +26,29 @@ commands = [ "test", "policy", "--settings=haproxy_route_policy.test_settings", + "--exclude-tag=auth", "-v2", ], ] dependency_groups = ["unit"] + +[env.unit_auth] +description = "Run auth unit tests" +commands = [ + [ + "coverage", + "run", + "manage.py", + "test", + "policy", + "--settings=haproxy_route_policy.test_settings_authenticated", + "--tag=auth", + "-v2", + ], +] +dependency_groups = ["unit", "auth"] + [env.lint] description = "Check code against coding style standards" commands = [ diff --git a/haproxy-route-policy/uv.lock b/haproxy-route-policy/uv.lock index 8caecae5..45b26319 100644 --- a/haproxy-route-policy/uv.lock +++ b/haproxy-route-policy/uv.lock @@ -194,6 +194,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, ] +[[package]] +name = "djangorestframework-simplejwt" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" }, +] + [[package]] name = "djangorestframework-stubs" version = "3.16.8" @@ -224,11 +238,15 @@ source = { virtual = "." } dependencies = [ { name = "django" }, { name = "djangorestframework" }, + { name = "djangorestframework-simplejwt" }, { name = "validators" }, { name = "whitenoise" }, ] [package.dev-dependencies] +auth = [ + { name = "djangorestframework-simplejwt" }, +] coverage-report = [ { name = "coverage" }, ] @@ -252,11 +270,13 @@ unit = [ requires-dist = [ { name = "django", specifier = ">=6.0.3" }, { name = "djangorestframework", specifier = ">=3.16.1" }, + { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "validators", specifier = ">=0.35.0" }, { name = "whitenoise", specifier = ">=6.12.0" }, ] [package.metadata.requires-dev] +auth = [{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }] coverage-report = [{ name = "coverage", extras = ["toml"], specifier = ">=7.13.5" }] lint = [ { name = "codespell", specifier = ">=2.4.2" }, @@ -411,6 +431,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" From 75663fabad6b4092556fac69c66b3d03b4a4602c Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 26 Mar 2026 16:12:02 +0100 Subject: [PATCH 2/3] add change artifact --- docs/release-notes/artifacts/pr0412.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/release-notes/artifacts/pr0412.yaml diff --git a/docs/release-notes/artifacts/pr0412.yaml b/docs/release-notes/artifacts/pr0412.yaml new file mode 100644 index 00000000..c90f99ce --- /dev/null +++ b/docs/release-notes/artifacts/pr0412.yaml @@ -0,0 +1,20 @@ +version_schema: 2 + +changes: + - title: Added authentication to haproxy-route-policy REST API + author: tphan025 + type: minor + description: > + Configured Django REST Framework with JWT and session-based authentication + as default authentication classes, requiring all API endpoints to be accessed + by authenticated users. Added test_settings_authenticated.py for auth-enabled + tests, a dedicated unit-auth tox environment, and integration tests verifying + that unauthenticated requests are rejected and authenticated requests succeed. + Added djangorestframework-simplejwt as a dependency. + urls: + pr: + - https://github.com/canonical/haproxy-operator/pull/412 + related_doc: + related_issue: + visibility: public + highlight: false From 7d62e126b5d0f9fb5d0fd6965227ce85eedf2648 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 26 Mar 2026 16:28:04 +0100 Subject: [PATCH 3/3] Add token urls --- haproxy-route-policy/haproxy_route_policy/urls.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/haproxy-route-policy/haproxy_route_policy/urls.py b/haproxy-route-policy/haproxy_route_policy/urls.py index 0fb11c0d..711c78c6 100644 --- a/haproxy-route-policy/haproxy_route_policy/urls.py +++ b/haproxy-route-policy/haproxy_route_policy/urls.py @@ -20,10 +20,18 @@ from django.contrib import admin from django.urls import include, path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, + TokenVerifyView, +) from policy import urls as policy_urls urlpatterns = [ path("admin/", admin.site.urls), + path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"), path("", include(policy_urls)), ]