1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

fix(api): schema and API docs need a login, and DRF views deny anonymous by default

This commit is contained in:
catborise 2026-10-07 16:01:48 +03:00
parent f889861e18
commit 841c412f76
4 changed files with 45 additions and 12 deletions

View file

@ -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://<webvirtcloud-address:port>/swagger/`
* **ReDoc UI:** `http://<webvirtcloud-address:port>/redoc/`

View file

@ -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",

View file

@ -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)

View file

@ -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}")