From 841c412f76c124de8e4a96aa7dc6d96f63c81d8a Mon Sep 17 00:00:00 2001 From: catborise Date: Wed, 7 Oct 2026 16:01:48 +0300 Subject: [PATCH] fix(api): schema and API docs need a login, and DRF views deny anonymous by default --- README.md | 8 ++++++- webvirtcloud/settings.py.template | 5 ++++ webvirtcloud/test_api_schema.py | 28 ++++++++++++++++++++++- webvirtcloud/test_authorization_matrix.py | 16 +++++-------- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e077b19a..e5a9cd12 100644 --- a/README.md +++ b/README.md @@ -492,6 +492,12 @@ Environment-driven database and email settings: copy the `DATABASES` and `EMAIL` blocks of the template into your `webvirtcloud/settings.py` to use them. PostgreSQL is for new installs; moving an existing sqlite database is not covered. +The API docs (`/swagger/`, `/redoc/`, `/api/schema/`) now need a login: add +`"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAuthenticated"]` to +`SPECTACULAR_SETTINGS` and `"DEFAULT_PERMISSION_CLASSES": +["rest_framework.permissions.IsAuthenticated"]` to `REST_FRAMEWORK` in your +`webvirtcloud/settings.py`. + ```bash # Go to Installation Directory cd /srv/webvirtcloud @@ -630,7 +636,7 @@ If you'd like to move a user from ldap to WebVirtCloud, just change its password WebVirtCloud provides a REST API powered by Django REST Framework and documented via `drf-spectacular`. -You can access the interactive API documentation and schema endpoints in your browser: +You can access the interactive API documentation and schema endpoints in your browser, after logging in: * **Swagger UI:** `http:///swagger/` * **ReDoc UI:** `http:///redoc/` diff --git a/webvirtcloud/settings.py.template b/webvirtcloud/settings.py.template index ed836954..48a95d13 100644 --- a/webvirtcloud/settings.py.template +++ b/webvirtcloud/settings.py.template @@ -357,6 +357,10 @@ REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework.authentication.SessionAuthentication", ], + # A view that forgets its own permission_classes is not public. + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], } SPECTACULAR_SETTINGS = { @@ -364,6 +368,7 @@ SPECTACULAR_SETTINGS = { "DESCRIPTION": "Webvirtcloud REST API", "VERSION": "v1", "SERVE_INCLUDE_SCHEMA": False, + "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAuthenticated"], "SWAGGER_UI_DIST": "SIDECAR", "SWAGGER_UI_FAVICON_HREF": "SIDECAR", "REDOC_DIST": "SIDECAR", diff --git a/webvirtcloud/test_api_schema.py b/webvirtcloud/test_api_schema.py index bb600239..700a8ed0 100644 --- a/webvirtcloud/test_api_schema.py +++ b/webvirtcloud/test_api_schema.py @@ -3,7 +3,11 @@ import os from django.contrib.auth import get_user_model from django.core.management import call_command from django.test import TestCase -from rest_framework.test import APIClient +from rest_framework.response import Response +from rest_framework.test import APIClient, APIRequestFactory +from rest_framework.views import APIView + +SCHEMA_URLS = ["/api/schema/", "/swagger.json", "/swagger/", "/redoc/"] class APISchemaTestCase(TestCase): @@ -16,3 +20,25 @@ class APISchemaTestCase(TestCase): client.force_login(admin) self.assertEqual(client.get("/api/v1/computes/abc/instances/").status_code, 404) self.assertEqual(client.get("/api/v1/instances/abc/").status_code, 404) + + +class SchemaPermissionTestCase(TestCase): + def test_schema_requires_login(self): + client = APIClient() + for url in SCHEMA_URLS: + self.assertEqual(client.get(url).status_code, 403, url) + + def test_any_user_may_read_the_schema(self): + user = get_user_model().objects.create_user("schema_reader", password="pw") + client = APIClient() + client.force_login(user) + for url in SCHEMA_URLS: + self.assertEqual(client.get(url).status_code, 200, url) + + def test_view_without_permission_classes_denies_anonymous(self): + class Probe(APIView): + def get(self, request): + return Response({}) + + response = Probe.as_view()(APIRequestFactory().get("/probe/")) + self.assertEqual(response.status_code, 403) diff --git a/webvirtcloud/test_authorization_matrix.py b/webvirtcloud/test_authorization_matrix.py index 7a289dc0..7717b5dd 100644 --- a/webvirtcloud/test_authorization_matrix.py +++ b/webvirtcloud/test_authorization_matrix.py @@ -21,7 +21,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.db import transaction from django.test import Client, TestCase -from django.urls import NoReverseMatch, URLPattern, URLResolver, get_resolver, reverse +from django.urls import NoReverseMatch, URLPattern, URLResolver, get_resolver, resolve, reverse from accounts.models import UserInstance, UserSSHKey from computes.models import Compute @@ -46,10 +46,10 @@ ALLOWED_FOR_ANY_USER = { "compute-instance-list": "API: filtered to the user's own instances", "instance-flavor-list": "API: flavor catalogue", "instance-flavor-detail": "API: flavor catalogue", - "schema": "OpenAPI schema (SERVE_PERMISSIONS not restricted yet)", - "schema-json": "OpenAPI schema (SERVE_PERMISSIONS not restricted yet)", - "schema-redoc": "API docs (SERVE_PERMISSIONS not restricted yet)", - "schema-swagger-ui": "API docs (SERVE_PERMISSIONS not restricted yet)", + "schema": "API docs: any authenticated user (SERVE_PERMISSIONS)", + "schema-json": "API docs: any authenticated user (SERVE_PERMISSIONS)", + "schema-redoc": "API docs: any authenticated user (SERVE_PERMISSIONS)", + "schema-swagger-ui": "API docs: any authenticated user (SERVE_PERMISSIONS)", } # URL name -> why an anonymous visitor may reach it. Everything else must @@ -60,10 +60,6 @@ ALLOWED_FOR_ANONYMOUS = { "ds_openstack_index": "cloud-init datasource, fetched by VMs that do not log in", "ds_openstack_metadata": "cloud-init datasource, fetched by VMs that do not log in", "ds_openstack_userdata": "cloud-init datasource, fetched by VMs that do not log in", - "schema": "OpenAPI schema (SERVE_PERMISSIONS not restricted yet)", - "schema-json": "OpenAPI schema (SERVE_PERMISSIONS not restricted yet)", - "schema-redoc": "API docs (SERVE_PERMISSIONS not restricted yet)", - "schema-swagger-ui": "API docs (SERVE_PERMISSIONS not restricted yet)", } # Endpoints that expose a VM's console (or its VNC password) and therefore @@ -234,7 +230,7 @@ class AuthorizationMatrixTestCase(TestCase): response = method(url) # DRF views opt out of login middleware and deny by their own # permission classes. - api_denied = url.startswith("/api/") and response.status_code in (401, 403) + api_denied = hasattr(resolve(url).func, "cls") and response.status_code in (401, 403) to_login = response.status_code == 302 and response["Location"].startswith(settings.LOGIN_URL) if not (api_denied or to_login): reachable.append(f"{method.__name__.upper()} {name} ({url}) -> {response.status_code}")