VYPR
Low severityNVD Advisory· Published Mar 26, 2024· Updated Aug 2, 2024

Unauthenticated views may expose information to anonymous users

CVE-2024-29199

Description

Nautobot is a Network Source of Truth and Network Automation Platform. A number of Nautobot URL endpoints were found to be improperly accessible to unauthenticated (anonymous) users. These endpoints will not disclose any Nautobot data to an unauthenticated user unless the Nautobot configuration variable EXEMPT_VIEW_PERMISSIONS is changed from its default value (an empty list) to permit access to specific data by unauthenticated users. This vulnerability is fixed in 1.6.16 and 2.1.9.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Nautobot had improperly accessible endpoints allowing unauthenticated access to certain views, but fixed in versions 1.6.16 and 2.1.9.

Vulnerability

Description

Nautobot, a Network Source of Truth and Network Automation Platform, contained a vulnerability where several URL endpoints were improperly accessible to unauthenticated (anonymous) users [1]. The root cause was a lack of authentication enforcement on these endpoints, which included paths such as /extras/job-results/<uuid:pk>/log-table/, /extras/secrets/provider/<str:provider_slug>/form/, and the /api/graphql/ endpoint [1][2]. Additionally, the REST API root views (e.g., /api/, /api/circuits/) did not require authentication by default [3][4].

Exploitation

An unauthenticated attacker could access these endpoints if the Nautobot instance is exposed to the network [1]. However, the Nautobot configuration variable EXEMPT_VIEW_PERMISSIONS defaults to an empty list, meaning no sensitive data is disclosed to unauthenticated users unless an administrator has explicitly granted such permissions [2]. Some endpoints, like /api/graphql/, now require authentication even when EXEMPT_VIEW_PERMISSIONS is configured [1].

Impact

The primary impact is the potential for information disclosure if an administrator has modified the EXEMPT_VIEW_PERMISSIONS setting to allow unauthenticated access to specific data [2]. Without such configuration, the vulnerability does not lead to data exposure. However, the presence of improperly accessible endpoints violates security best practices and could be leveraged in conjunction with other misconfigurations [1].

Mitigation

This vulnerability is fixed in Nautobot versions 1.6.16 and 2.1.9 [2]. Users are advised to upgrade to these versions or later. As a workaround, administrators should ensure that EXEMPT_VIEW_PERMISSIONS remains an empty list unless explicit unauthenticated access is required [1].

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
nautobotPyPI
< 1.6.161.6.16
nautobotPyPI
>= 2.0.0, < 2.1.92.1.9

Affected products

2

Patches

2
2fd95c365f84

[LTM] View authentication and permission fixes (#5465)

https://github.com/nautobot/nautobotGlenn MatthewsMar 25, 2024via ghsa
47 files changed · +517 226
  • changes/5465.added+2 0 added
    @@ -0,0 +1,2 @@
    +Added `nautobot.apps.utils.get_url_for_url_pattern` and `nautobot.apps.utils.get_url_patterns` lookup functions.
    +Added `nautobot.apps.views.GenericView` base class.
    
  • changes/5465.changed+3 0 added
    @@ -0,0 +1,3 @@
    +Added support for `view_name` and `view_description` optional parameters when instantiating a `nautobot.apps.api.OrderedDefaultRouter`. Specifying these parameters is to be preferred over defining a custom `APIRootView` subclass when defining App API URLs.
    +Added requirement for user authentication by default on the `nautobot.core.api.AuthenticatedAPIRootView` class. As a consequence, viewing the browsable REST API root endpoints (e.g. `/api/`, `/api/circuits/`, `/api/dcim/`, etc.) now requires user authentication.
    +Added requirement for user authentication to access `/api/docs/` and `/graphql/` even when `HIDE_RESTRICTED_UI` is False.
    
  • changes/5465.documentation+1 0 added
    @@ -0,0 +1 @@
    +Updated example views in the App developer documentation to include `ObjectPermissionRequiredMixin` or `LoginRequiredMixin` as appropriate best practices.
    
  • changes/5465.fixed+1 0 added
    @@ -0,0 +1 @@
    +Fixed a 500 error when accessing any of the `/dcim/<port-type>/<uuid>/connect/<termination_b_type>/` view endpoints with an invalid/nonexistent `termination_b_type` string.
    
  • changes/5465.housekeeping+1 0 added
    @@ -0,0 +1 @@
    +Updated custom views in the `example_plugin` to use the new `GenericView` base class as a best practice.
    
  • changes/5465.security+8 0 added
    @@ -0,0 +1,8 @@
    +Added requirement for user authentication to access the endpoint `/extras/job-results/<uuid:pk>/log-table/`; furthermore it will not allow an authenticated user to view log entries for a JobResult they don't otherwise have permission to view. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added narrower permissions enforcement on the endpoints `/extras/git-repositories/<str:slug>/sync/` and `/extras/git-repositories/<str:slug>/dry-run/`; a user who has `change` permissions for a subset of Git repositories is no longer permitted to sync or dry-run other repositories for which they lack the appropriate permissions. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added narrower permissions enforcement on the `/api/dcim/connected-device/?peer_device=...&?peer_interface=...` REST API endpoint; a user who has `view` permissions for a subset of interfaces is no longer permitted to query other interfaces for which they lack permissions. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added narrower permissions enforcement on all `<app>/<model>/<lookup>/notes/` UI endpoints; a user must now have the appropriate `extras.view_note` permissions to view existing notes. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added requirement for user authentication to access the REST API endpoints `/api/redoc/`, `/api/swagger/`, `/api/swagger.json`, and `/api/swagger.yaml`. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added requirement for user authentication to access the `/api/graphql` REST API endpoint, even when `EXEMPT_VIEW_PERMISSIONS` is configured. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added requirement for user authentication to access the endpoints `/dcim/racks/<uuid>/dynamic-groups/`, `/dcim/devices/<uuid>/dynamic-groups/`, `/ipam/prefixes/<uuid>/dynamic-groups/`, `/ipam/ip-addresses/<uuid>/dynamic-groups/`, `/virtualization/clusters/<uuid>/dynamic-groups/`, and `/virtualization/virtual-machines/<uuid>/dynamic-groups/`, even when `EXEMPT_VIEW_PERMISSIONS` is configured. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added requirement for user authentication to access the endpoint `/extras/secrets/provider/<str:provider_slug>/form/`. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    
  • examples/example_plugin/example_plugin/api/urls.py+1 1 modified
    @@ -4,7 +4,7 @@
     from example_plugin.api.views import AnotherExampleModelViewSet, ExampleModelViewSet, ExampleModelWebhook
     
     
    -router = OrderedDefaultRouter()
    +router = OrderedDefaultRouter(view_name="Example App")
     router.register("models", ExampleModelViewSet)
     router.register("other-models", AnotherExampleModelViewSet)
     
    
  • examples/example_plugin/example_plugin/views.py+3 4 modified
    @@ -1,5 +1,4 @@
     from django.shortcuts import HttpResponse, render
    -from django.views.generic import View
     
     from nautobot.apps import views
     from nautobot.circuits.models import Circuit
    @@ -45,12 +44,12 @@ class DeviceDetailPluginTabTwoView(views.ObjectView):
         template_name = "example_plugin/tab_device_detail_2.html"
     
     
    -class ExamplePluginHomeView(View):
    +class ExamplePluginHomeView(views.GenericView):
         def get(self, request):
             return render(request, "example_plugin/home.html")
     
     
    -class ExamplePluginConfigView(View):
    +class ExamplePluginConfigView(views.GenericView):
         def get(self, request):
             """Render the configuration page for this plugin.
     
    @@ -103,6 +102,6 @@ class AnotherExampleModelUIViewSet(
         table_class = tables.AnotherExampleModelTable
     
     
    -class ViewToBeOverridden(View):
    +class ViewToBeOverridden(views.GenericView):
         def get(self, request, *args, **kwargs):
             return HttpResponse("I am a view in the example plugin which will be overridden by another plugin.")
    
  • examples/example_plugin_with_view_override/example_plugin_with_view_override/views.py+3 2 modified
    @@ -1,10 +1,11 @@
     """Views for plugin_with_view_override."""
     
     from django.shortcuts import HttpResponse
    -from django.views import generic
     
    +from nautobot.apps.views import GenericView
     
    -class ViewOverride(generic.View):
    +
    +class ViewOverride(GenericView):
         def get(self, request, *args, **kwargs):
             return HttpResponse("Hello world! I'm an overridden view.")
     
    
  • mkdocs.yml+1 0 modified
    @@ -297,6 +297,7 @@ nav:
               - nautobot.apps.testing: "code-reference/nautobot/apps/testing.md"
               - nautobot.apps.ui: "code-reference/nautobot/apps/ui.md"
               - nautobot.apps.urls: "code-reference/nautobot/apps/urls.md"
    +          - nautobot.apps.utils: "code-reference/nautobot/apps/utils.md"
               - nautobot.apps.views: "code-reference/nautobot/apps/views.md"
       - Core Developer Guide:
           - Introduction: "development/index.md"
    
  • nautobot/apps/utils.py+11 0 added
    @@ -0,0 +1,11 @@
    +"""Nautobot utility functions."""
    +
    +from nautobot.utilities.utils import (
    +    get_url_for_url_pattern,
    +    get_url_patterns,
    +)
    +
    +__all__ = (
    +    "get_url_for_url_pattern",
    +    "get_url_patterns",
    +)
    
  • nautobot/apps/views.py+2 1 modified
    @@ -1,6 +1,6 @@
     """Utilities for apps to implement UI views."""
     
    -from nautobot.core.views.generic import ObjectView
    +from nautobot.core.views.generic import GenericView, ObjectView
     from nautobot.core.views.mixins import (
         ObjectBulkCreateViewMixin,
         ObjectBulkDestroyViewMixin,
    @@ -15,6 +15,7 @@
     from nautobot.core.views.viewsets import NautobotUIViewSet
     
     __all__ = (
    +    "GenericView",
         "NautobotUIViewSet",
         "ObjectBulkCreateViewMixin",
         "ObjectBulkDestroyViewMixin",
    
  • nautobot/circuits/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     from . import views
     
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.CircuitsRootView
    +router = OrderedDefaultRouter(view_name="Circuits")
     
     # Providers
     router.register("providers", views.ProviderViewSet)
    
  • nautobot/circuits/api/views.py+0 12 modified
    @@ -1,22 +1,10 @@
    -from rest_framework.routers import APIRootView
    -
     from nautobot.circuits import filters
     from nautobot.circuits.models import Provider, CircuitTermination, CircuitType, Circuit, ProviderNetwork
     from nautobot.dcim.api.views import PathEndpointMixin
     from nautobot.extras.api.views import NautobotModelViewSet, StatusViewSetMixin
     from nautobot.utilities.utils import count_related
     from . import serializers
     
    -
    -class CircuitsRootView(APIRootView):
    -    """
    -    Circuits API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Circuits"
    -
    -
     #
     # Providers
     #
    
  • nautobot/core/api/routers.py+35 2 modified
    @@ -1,12 +1,34 @@
     from collections import OrderedDict
    +import logging
     
    -from rest_framework.routers import DefaultRouter
    +from rest_framework.permissions import IsAuthenticated
    +from rest_framework.routers import APIRootView, DefaultRouter
    +
    +logger = logging.getLogger(__name__)
    +
    +
    +class AuthenticatedAPIRootView(APIRootView):
    +    """
    +    Extends DRF's base APIRootView class to enforce user authentication.
    +    """
    +
    +    permission_classes = [IsAuthenticated]
    +
    +    name = None
    +    description = None
     
     
     class OrderedDefaultRouter(DefaultRouter):
    -    def __init__(self, *args, **kwargs):
    +    APIRootView = AuthenticatedAPIRootView
    +
    +    def __init__(self, *args, view_name=None, view_description=None, **kwargs):
             super().__init__(*args, **kwargs)
     
    +        self.view_name = view_name
    +        if view_name and not view_description:
    +            view_description = f"{view_name} API root view"
    +        self.view_description = view_description
    +
             # Extend the list view mappings to support the DELETE operation
             self.routes[0].mapping.update(
                 {
    @@ -22,7 +44,18 @@ def get_api_root_view(self, api_urls=None):
             """
             api_root_dict = OrderedDict()
             list_name = self.routes[0].name
    +
             for prefix, _viewset, basename in sorted(self.registry, key=lambda x: x[0]):
                 api_root_dict[prefix] = list_name.format(basename=basename)
     
    +        if issubclass(self.APIRootView, AuthenticatedAPIRootView):
    +            return self.APIRootView.as_view(
    +                api_root_dict=api_root_dict, name=self.view_name, description=self.view_description
    +            )
    +        # Fallback for the established practice of overriding self.APIRootView with a custom class
    +        logger.warning(
    +            "Something has changed an OrderedDefaultRouter's APIRootView attribute to a custom class. "
    +            "Please verify that class %s implements appropriate authentication controls.",
    +            self.APIRootView.__name__,
    +        )
             return self.APIRootView.as_view(api_root_dict=api_root_dict)
    
  • nautobot/core/api/views.py+12 17 modified
    @@ -17,7 +17,7 @@
     from rest_framework.views import APIView
     from rest_framework.viewsets import ModelViewSet as ModelViewSet_
     from rest_framework.viewsets import ReadOnlyModelViewSet as ReadOnlyModelViewSet_
    -from rest_framework.permissions import AllowAny, IsAuthenticated
    +from rest_framework.permissions import IsAuthenticated
     from rest_framework.exceptions import PermissionDenied, ParseError
     from drf_spectacular.plumbing import get_relative_url, set_query_parameters
     from drf_spectacular.renderers import OpenApiJsonRenderer
    @@ -35,8 +35,8 @@
     from nautobot.core.celery import app as celery_app
     from nautobot.core.api import BulkOperationSerializer
     from nautobot.core.api.exceptions import SerializerNotFound
    +from nautobot.core.api.routers import AuthenticatedAPIRootView
     from nautobot.utilities.api import get_serializer_for_model
    -from nautobot.utilities.config import get_settings_or_config
     from nautobot.utilities.utils import (
         get_all_lookup_expr_for_field,
         get_filterset_parameter_form_field,
    @@ -323,18 +323,17 @@ class ReadOnlyModelViewSet(NautobotAPIVersionMixin, ModelViewSetMixin, ReadOnlyM
     #
     
     
    -class APIRootView(NautobotAPIVersionMixin, APIView):
    -    """
    -    This is the root of the REST API. API endpoints are arranged by app and model name; e.g. `/api/dcim/sites/`.
    +class APIRootView(NautobotAPIVersionMixin, AuthenticatedAPIRootView):
         """
    +    This is the root of the REST API.
     
    -    _ignore_model_permissions = True
    +    API endpoints are arranged by app and model name; e.g. `/api/dcim/locations/`.
    +    """
     
    -    def get_view_name(self):
    -        return "API Root"
    +    name = "API Root"
     
         @extend_schema(exclude=True)
    -    def get(self, request, format=None):  # pylint: disable=redefined-builtin
    +    def get(self, request, *args, format=None, **kwargs):  # pylint: disable=redefined-builtin
             return Response(
                 OrderedDict(
                     (
    @@ -478,11 +477,6 @@ class FakeOpenAPIRenderer(OpenApiJsonRenderer):
         @extend_schema(exclude=True)
         def get(self, request, *args, **kwargs):
             """Fix up the rendering of the Swagger UI to work with Nautobot's UI."""
    -        if not request.user.is_authenticated and get_settings_or_config("HIDE_RESTRICTED_UI"):
    -            doc_url = reverse("api_docs")
    -            login_url = reverse(settings.LOGIN_URL)
    -            return redirect(f"{login_url}?next={doc_url}")
    -
             # For backward compatibility wtih drf-yasg, `/api/docs/?format=openapi` is a redirect to the JSON schema.
             if request.GET.get("format") == "openapi":
                 return redirect("schema_json", permanent=True)
    @@ -511,11 +505,12 @@ class NautobotSpectacularRedocView(APIVersioningGetSchemaURLMixin, SpectacularRe
     class GraphQLDRFAPIView(NautobotAPIVersionMixin, APIView):
         """
         API View for GraphQL to integrate properly with DRF authentication mechanism.
    -    The code is a stripped down version of graphene-django default View
    -    https://github.com/graphql-python/graphene-django/blob/main/graphene_django/views.py#L57
         """
     
    -    permission_classes = [AllowAny]
    +    # The code is a stripped down version of graphene-django default View
    +    # https://github.com/graphql-python/graphene-django/blob/main/graphene_django/views.py#L57
    +
    +    permission_classes = [IsAuthenticated]
         graphql_schema = None
         executor = None
         backend = None
    
  • nautobot/core/settings.py+1 0 modified
    @@ -243,6 +243,7 @@
         # trim it from all of the individual paths correspondingly.
         # See also https://github.com/nautobot/nautobot-ansible/pull/135 for an example of why this is desirable.
         "SERVERS": [{"url": "/api"}],
    +    "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAuthenticated"],
         "SCHEMA_PATH_PREFIX": "/api",
         "SCHEMA_PATH_PREFIX_TRIM": True,
         # use sidecar - locally packaged UI files, not CDN
    
  • nautobot/core/tests/integration/test_view_authentication.py+72 0 added
    @@ -0,0 +1,72 @@
    +import re
    +
    +from django.test import override_settings, tag
    +
    +from nautobot.utilities.testing import TestCase
    +from nautobot.utilities.utils import get_url_for_url_pattern, get_url_patterns
    +
    +
    +@tag("integration")
    +class AuthenticationEnforcedTestCase(TestCase):
    +    r"""
    +    Test that all\* registered views require authentication to access.
    +
    +    \* with a very small number of known exceptions such as login and logout views.
    +    """
    +
    +    @override_settings(HIDE_RESTRICTED_UI=True)
    +    def test_all_views_require_authentication(self):
    +        self.client.logout()
    +        url_patterns = get_url_patterns()
    +
    +        for url_pattern in url_patterns:
    +            with self.subTest(url_pattern=url_pattern):
    +                url = get_url_for_url_pattern(url_pattern)
    +                response = self.client.get(url, follow=True)
    +
    +                if response.status_code == 405:  # Method not allowed
    +                    response = self.client.post(url, follow=True)
    +
    +                # Is a view that *should* be open to unauthenticated users?
    +                if url in [
    +                    "/admin/login/",
    +                    "/api/plugins/example-plugin/webhook/",
    +                    "/health/",
    +                    "/login/",
    +                    "/media-failure/",
    +                    "/metrics/",
    +                ]:
    +                    self.assertHttpStatus(response, 200, msg=url)
    +                elif response.status_code == 200:
    +                    # UI views generally should redirect unauthenticated users to the appropriate login page
    +                    if url.startswith("/extras/jobs/results/"):
    +                        redirect_url = f"/login/?next={re.sub('jobs/results', 'job-results', url)}"
    +                    elif url.startswith("/admin"):
    +                        if "logout" in url:
    +                            # /admin/logout/ sets next=/admin/ because having login redirect to logout would be silly
    +                            redirect_url = "/admin/login/?next=/admin/"
    +                        else:
    +                            redirect_url = f"/admin/login/?next={url}"
    +                    else:
    +                        if "logout" in url:
    +                            # /logout/ sets next=/ because having login redirect back to logout would be silly
    +                            redirect_url = "/login/?next=/"
    +                        else:
    +                            redirect_url = f"/login/?next={url}"
    +                    self.assertRedirects(response, redirect_url)
    +                elif response.status_code != 403:
    +                    if any(
    +                        url.startswith(path)
    +                        for path in [
    +                            "/complete/",  # social auth
    +                            "/login/",  # social auth
    +                            "/media/",  # MEDIA_ROOT
    +                            "/plugins/example-plugin/docs/",  # STATIC_ROOT
    +                        ]
    +                    ):
    +                        self.assertEqual(response.status_code, 404)
    +                    else:
    +                        self.fail(
    +                            f"Unexpected {response.status_code} response at {url}: "
    +                            + response.content.decode(response.charset)
    +                        )
    
  • nautobot/core/tests/test_graphql.py+2 14 modified
    @@ -593,21 +593,9 @@ def test_graphql_api_token_no_group_exempt(self):
             self.assertEqual(names, ["Rack 1-1", "Rack 1-2", "Rack 2-1", "Rack 2-2"])
     
         def test_graphql_api_no_token(self):
    -        """Validate unauthenticated users are not able to query anything by default."""
    +        """Validate unauthenticated users are not able to query anything."""
             response = self.client.post(self.api_url, {"query": self.get_racks_query}, format="json")
    -        self.assertEqual(response.status_code, status.HTTP_200_OK)
    -        self.assertIsInstance(response.data["data"]["racks"], list)
    -        names = [item["name"] for item in response.data["data"]["racks"]]
    -        self.assertEqual(names, [])
    -
    -    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    -    def test_graphql_api_no_token_exempt(self):
    -        """Validate unauthenticated users are able to query based on the exempt permissions."""
    -        response = self.client.post(self.api_url, {"query": self.get_racks_query}, format="json")
    -        self.assertEqual(response.status_code, status.HTTP_200_OK)
    -        self.assertIsInstance(response.data["data"]["racks"], list)
    -        names = [item["name"] for item in response.data["data"]["racks"]]
    -        self.assertEqual(names, ["Rack 1-1", "Rack 1-2", "Rack 2-1", "Rack 2-2"])
    +        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
     
         def test_graphql_api_wrong_token(self):
             """Validate a wrong token return 403."""
    
  • nautobot/core/tests/test_views.py+39 34 modified
    @@ -294,45 +294,50 @@ def test_sso_login_button_visible(self):
             sso_login_search_result = self.make_request()
             self.assertIsNotNone(sso_login_search_result)
     
    -    @override_settings(HIDE_RESTRICTED_UI=True, BANNER_TOP="Hello, Banner Top", BANNER_BOTTOM="Hello, Banner Bottom")
    -    def test_routes_redirect_back_to_login_if_hide_restricted_ui_true(self):
    -        """Assert that api docs and graphql redirects to login page if user is unauthenticated and HIDE_RESTRICTED_UI=True."""
    +    @override_settings(HIDE_RESTRICTED_UI=True)
    +    def test_graphql_redirects_back_to_login_if_hide_restricted_ui_true(self):
    +        """Assert that graphql redirects to login page if user is unauthenticated."""
             self.client.logout()
             headers = {"HTTP_ACCEPT": "text/html"}
    -        urls = [reverse("api_docs"), reverse("graphql")]
    -        for url in urls:
    -            response = self.client.get(url, follow=True, **headers)
    -            self.assertHttpStatus(response, 200)
    -            redirect_chain = [(f"/login/?next={url}", 302)]
    -            self.assertEqual(response.redirect_chain, redirect_chain)
    -            response_content = response.content.decode(response.charset).replace("\n", "")
    -            # Assert Footer items(`self.footer_elements`), Banner and Banner Top is hidden
    -            for footer_text in self.footer_elements:
    -                self.assertNotIn(footer_text, response_content)
    -            # Only API Docs implements BANNERS
    -            if url == urls[0]:
    -                self.assertNotIn("Hello, Banner Top", response_content)
    -                self.assertNotIn("Hello, Banner Bottom", response_content)
    -
    -    @override_settings(HIDE_RESTRICTED_UI=False, BANNER_TOP="Hello, Banner Top", BANNER_BOTTOM="Hello, Banner Bottom")
    -    def test_routes_no_redirect_back_to_login_if_hide_restricted_ui_false(self):
    -        """Assert that api docs and graphql do not redirects to login page if user is unauthenticated and HIDE_RESTRICTED_UI=False."""
    +        url = reverse("graphql")
    +        response = self.client.get(url, follow=True, **headers)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +        response_content = response.content.decode(response.charset).replace("\n", "")
    +        for footer_text in self.footer_elements:
    +            self.assertNotIn(footer_text, response_content)
    +
    +    @override_settings(HIDE_RESTRICTED_UI=False)
    +    def test_routes_redirect_back_to_login_if_hide_restricted_ui_false(self):
    +        """Assert that GraphQL redirects to login page if user is unauthenticated and HIDE_RESTRICTED_UI=False."""
             self.client.logout()
             headers = {"HTTP_ACCEPT": "text/html"}
    -        urls = [reverse("api_docs"), reverse("graphql")]
    +        url = reverse("graphql")
    +        response = self.client.get(url, follow=True, **headers)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +        response_content = response.content.decode(response.charset).replace("\n", "")
    +        # Assert Footer items(`self.footer_elements`), Banner and Banner Top is not hidden
    +        for footer_text in self.footer_elements:
    +            self.assertInHTML(footer_text, response_content)
    +
    +    def test_api_docs_403_unauthenticated(self):
    +        """Assert that api docs return a 403 Forbidden if user is unauthenticated."""
    +        self.client.logout()
    +        urls = [
    +            reverse("api_docs"),
    +            reverse("api_redocs"),
    +            reverse("schema"),
    +            reverse("schema_json"),
    +            reverse("schema_yaml"),
    +        ]
             for url in urls:
    -            response = self.client.get(url, **headers)
    -            self.assertHttpStatus(response, 200)
    -            self.assertEqual(response.request["PATH_INFO"], url)
    -            response_content = response.content.decode(response.charset).replace("\n", "")
    -            # Assert Footer items(`self.footer_elements`), Banner and Banner Top is not hidden
    -            for footer_text in self.footer_elements:
    -                self.assertInHTML(footer_text, response_content)
    -
    -            # Only API Docs implements BANNERS
    -            if url == urls[0]:
    -                self.assertInHTML("Hello, Banner Top", response_content)
    -                self.assertInHTML("Hello, Banner Bottom", response_content)
    +            with override_settings(HIDE_RESTRICTED_UI=True):
    +                response = self.client.get(url)
    +                self.assertHttpStatus(response, 403)
    +            with override_settings(HIDE_RESTRICTED_UI=False):
    +                response = self.client.get(url)
    +                self.assertHttpStatus(response, 403)
     
     
     class MetricsViewTestCase(TestCase):
    
  • nautobot/core/views/generic.py+9 0 modified
    @@ -4,6 +4,7 @@
     
     from django.conf import settings
     from django.contrib import messages
    +from django.contrib.auth.mixins import LoginRequiredMixin
     from django.contrib.contenttypes.models import ContentType
     from django.core.exceptions import (
         FieldDoesNotExist,
    @@ -53,6 +54,14 @@
     from nautobot.utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
     
     
    +class GenericView(LoginRequiredMixin, View):
    +    """
    +    Base class for non-object-related views.
    +
    +    Enforces authentication, which Django's base View does not by default.
    +    """
    +
    +
     class ObjectView(ObjectPermissionRequiredMixin, View):
         """
         Retrieve a single object for display.
    
  • nautobot/core/views/__init__.py+4 9 modified
    @@ -7,9 +7,9 @@
     import prometheus_client
     from django.conf import settings
     from django.contrib.auth.decorators import permission_required
    -from django.contrib.auth.mixins import AccessMixin
    +from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
     from django.http import HttpResponseServerError, JsonResponse, HttpResponseForbidden, HttpResponse
    -from django.shortcuts import get_object_or_404, redirect, render
    +from django.shortcuts import get_object_or_404, render
     from django.template import loader, RequestContext, Template
     from django.template.exceptions import TemplateDoesNotExist
     from django.urls import reverse
    @@ -168,7 +168,7 @@ def get(self, request):
             )
     
     
    -class StaticMediaFailureView(View):
    +class StaticMediaFailureView(View):  # NOT using LoginRequiredMixin here as this may happen even on the login page
         """
         Display a user-friendly error message with troubleshooting tips when a static media file fails to load.
         """
    @@ -226,13 +226,8 @@ def csrf_failure(request, reason="", template_name="403_csrf_failure.html"):
         return HttpResponseForbidden(t.render(context), content_type="text/html")
     
     
    -class CustomGraphQLView(GraphQLView):
    +class CustomGraphQLView(LoginRequiredMixin, GraphQLView):
         def render_graphiql(self, request, **data):
    -        if not request.user.is_authenticated and get_settings_or_config("HIDE_RESTRICTED_UI"):
    -            graphql_url = reverse("graphql")
    -            login_url = reverse(settings.LOGIN_URL)
    -            return redirect(f"{login_url}?next={graphql_url}")
    -
             query_slug = request.GET.get("slug")
             if query_slug:
                 data["obj"] = GraphQLQuery.objects.get(slug=query_slug)
    
  • nautobot/core/views/mixins.py+6 4 modified
    @@ -94,8 +94,6 @@ def get_permissions_for_model(self, model, actions):
             """
             permissions = []
             for action in actions:
    -            if action not in ("view", "add", "change", "delete"):
    -                raise ValueError(f"Unsupported action: {action}")
                 permissions.append(f"{model._meta.app_label}.{action}_{model._meta.model_name}")
             return permissions
     
    @@ -105,7 +103,7 @@ def get_required_permission(self):
             """
             queryset = self.get_queryset()
             try:
    -            permissions = [PERMISSIONS_ACTION_MAP[self.action]]
    +            permissions = [self.get_action()]
             except KeyError:
                 messages.error(
                     self.request,
    @@ -326,7 +324,11 @@ def get_queryset(self):
             Override the original `get_queryset()` to apply permission specific to the user and action.
             """
             queryset = super().get_queryset()
    -        return queryset.restrict(self.request.user, PERMISSIONS_ACTION_MAP[self.action])
    +        return queryset.restrict(self.request.user, self.get_action())
    +
    +    def get_action(self):
    +        """Helper method for retrieving action and if action not set defaulting to action name."""
    +        return PERMISSIONS_ACTION_MAP.get(self.action, self.action)
     
         def get_extra_context(self, request, instance=None):
             """
    
  • nautobot/dcim/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     from . import views
     
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.DCIMRootView
    +router = OrderedDefaultRouter(view_name="DCIM")
     
     # Sites
     router.register("regions", views.RegionViewSet)
    
  • nautobot/dcim/api/views.py+1 12 modified
    @@ -13,7 +13,6 @@
     from rest_framework.mixins import ListModelMixin
     from rest_framework.permissions import IsAuthenticated
     from rest_framework.response import Response
    -from rest_framework.routers import APIRootView
     from rest_framework.viewsets import GenericViewSet, ViewSet
     
     from nautobot.circuits.models import Circuit
    @@ -73,16 +72,6 @@
     from . import serializers
     from .exceptions import MissingFilterException
     
    -
    -class DCIMRootView(APIRootView):
    -    """
    -    DCIM API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "DCIM"
    -
    -
     # Mixins
     
     
    @@ -875,7 +864,7 @@ def list(self, request):
     
             # Determine local interface from peer interface's connection
             peer_interface = get_object_or_404(
    -            Interface.objects.all(),
    +            Interface.objects.restrict(request.user, "view"),
                 device__name=peer_device_name,
                 name=peer_interface_name,
             )
    
  • nautobot/dcim/tests/test_api.py+3 0 modified
    @@ -2276,7 +2276,10 @@ def setUp(self):
         def test_get_connected_device(self):
             url = reverse("dcim-api:connected-device-list")
             response = self.client.get(url + "?peer_device=TestDevice2&peer_interface=eth0", **self.header)
    +        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
     
    +        self.add_permissions("dcim.view_interface")
    +        response = self.client.get(url + "?peer_device=TestDevice2&peer_interface=eth0", **self.header)
             self.assertHttpStatus(response, status.HTTP_200_OK)
             self.assertEqual(response.data["name"], self.device1.name)
     
    
  • nautobot/dcim/views.py+5 2 modified
    @@ -11,7 +11,7 @@
         MultipleHiddenInput,
         modelformset_factory,
     )
    -from django.shortcuts import get_object_or_404, redirect, render
    +from django.shortcuts import get_object_or_404, HttpResponse, redirect, render
     from django.utils.html import format_html
     from django.views.generic import View
     from django_tables2 import RequestConfig
    @@ -2578,7 +2578,7 @@ def dispatch(self, request, *args, **kwargs):
                 "rear-port": forms.ConnectCableToRearPortForm,
                 "power-feed": forms.ConnectCableToPowerFeedForm,
                 "circuit-termination": forms.ConnectCableToCircuitTerminationForm,
    -        }[kwargs.get("termination_b_type")]
    +        }.get(kwargs.get("termination_b_type"), None)
     
             return super().dispatch(request, *args, **kwargs)
     
    @@ -2595,6 +2595,9 @@ def alter_obj(self, obj, request, url_args, url_kwargs):
             return obj
     
         def get(self, request, *args, **kwargs):
    +        if self.model_form is None:
    +            return HttpResponse(status_code=400)
    +
             obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
     
             # Parse initial data manually to avoid setting field values as lists
    
  • nautobot/docs/plugins/development.md+12 3 modified
    @@ -1200,12 +1200,13 @@ The use of `generic` Django views can aid in app development. As an example, let
     ```python
     # views.py
     from django.shortcuts import render
    -from django.views.generic import View
    +
    +from nautobot.apps.views import GenericView
     
     from .models import Animal
     
     
    -class RandomAnimalView(View):
    +class RandomAnimalView(GenericView):
         """Display a randomly-selected Animal."""
     
         def get(self, request):
    @@ -1215,6 +1216,9 @@ class RandomAnimalView(View):
             })
     ```
     
    +!!! tip
    +    The `nautobot.apps.views.GenericView` class was added in Nautobot 1.6.16 and 2.1.9. If you're developing against an earlier version, you can use `django.views.generic.View` in combination with the `django.contrib.auth.mixins.LoginRequiredMixin` instead.
    +
     This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/nautobot_animal_sounds/` within the app source directory. (We use the app's name as a subdirectory to guard against naming collisions with other apps.) Then, create a template named `animal.html` as described below.
     
     ### Utilizing Nautobot Generic Views
    @@ -1386,8 +1390,13 @@ A simple example to override the device detail view:
     from django.shortcuts import HttpResponse
     from django.views import generic
     
    +from nautobot.utilities.views import ObjectPermissionRequiredMixin
    +
    +
    +class DeviceViewOverride(ObjectPermissionRequiredMixin, generic.View):
    +    def get_required_permission(self):
    +        return "dcim.view_device"
     
    -class DeviceViewOverride(generic.View):
         def get(self, request, *args, **kwargs):
             return HttpResponse(("Hello world! I'm a view which "
                                  "overrides the device object detail view."))
    
  • nautobot/extras/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     from . import views
     
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.ExtrasRootView
    +router = OrderedDefaultRouter(view_name="Extras")
     
     # Computed Fields
     router.register("computed-fields", views.ComputedFieldViewSet)
    
  • nautobot/extras/api/views.py+0 10 modified
    @@ -15,7 +15,6 @@
     from rest_framework.parsers import JSONParser, MultiPartParser
     from rest_framework.permissions import IsAuthenticated
     from rest_framework.response import Response
    -from rest_framework.routers import APIRootView
     from rest_framework import mixins, viewsets
     
     from nautobot.core.api.authentication import TokenPermissions
    @@ -75,15 +74,6 @@
     from . import nested_serializers, serializers
     
     
    -class ExtrasRootView(APIRootView):
    -    """
    -    Extras API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Extras"
    -
    -
     class NotesViewSetMixin:
         @extend_schema(methods=["get"], filters=False, responses={200: serializers.NoteSerializer(many=True)})
         @extend_schema(
    
  • nautobot/extras/plugins/views.py+7 10 modified
    @@ -2,7 +2,6 @@
     
     from django.apps import apps
     from django.conf import settings
    -from django.contrib.auth.mixins import LoginRequiredMixin
     from django.http import Http404
     from django.shortcuts import render
     from django.urls.exceptions import NoReverseMatch
    @@ -15,7 +14,8 @@
     
     from django_tables2 import RequestConfig
     
    -from nautobot.core.api.views import NautobotAPIVersionMixin
    +from nautobot.core.api.views import AuthenticatedAPIRootView, NautobotAPIVersionMixin
    +from nautobot.core.views.generic import GenericView
     from nautobot.utilities.forms import TableConfigForm
     from nautobot.utilities.paginator import EnhancedPaginator, get_paginate_count
     from nautobot.extras.plugins.tables import InstalledPluginsTable
    @@ -67,7 +67,7 @@ def get(self, request):
             )
     
     
    -class InstalledPluginDetailView(LoginRequiredMixin, View):
    +class InstalledPluginDetailView(GenericView):
         """
         View for showing details of an installed plugin.
         """
    @@ -92,7 +92,6 @@ class InstalledPluginsAPIView(NautobotAPIVersionMixin, APIView):
         """
     
         permission_classes = [permissions.IsAdminUser]
    -    _ignore_model_permissions = True
     
         def get_view_name(self):
             return "Installed Plugins"
    @@ -115,11 +114,9 @@ def get(self, request, format=None):  # pylint: disable=redefined-builtin
             return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS])
     
     
    -class PluginsAPIRootView(NautobotAPIVersionMixin, APIView):
    -    _ignore_model_permissions = True
    -
    -    def get_view_name(self):
    -        return "Plugins"
    +class PluginsAPIRootView(AuthenticatedAPIRootView):
    +    name = "Plugins"
    +    description = "API extension point for installed Nautobot Plugins"
     
         @staticmethod
         def _get_plugin_entry(plugin, app_config, request, format_):
    @@ -141,7 +138,7 @@ def _get_plugin_entry(plugin, app_config, request, format_):
             return entry
     
         @extend_schema(exclude=True)
    -    def get(self, request, format=None):  # pylint: disable=redefined-builtin
    +    def get(self, request, *args, format=None, **kwargs):  # pylint: disable=redefined-builtin
             entries = []
             for plugin in settings.PLUGINS:
                 app_config = apps.get_app_config(plugin)
    
  • nautobot/extras/tests/test_views.py+108 0 modified
    @@ -27,6 +27,7 @@
         CustomFieldTypeChoices,
         JobExecutionType,
         JobSourceChoices,
    +    LogLevelChoices,
         ObjectChangeActionChoices,
         SecretsGroupAccessTypeChoices,
         SecretsGroupSecretTypeChoices,
    @@ -44,6 +45,7 @@
         GraphQLQuery,
         Job,
         JobButton,
    +    JobLogEntry,
         JobResult,
         Note,
         ObjectChange,
    @@ -616,6 +618,13 @@ def setUpTestData(cls):
             DynamicGroup.objects.create(name="DG 2", slug="dg-2", content_type=content_type)
             DynamicGroup.objects.create(name="DG 3", slug="dg-3", content_type=content_type)
     
    +        manufacturer = Manufacturer.objects.create(name="Manufacturer 1", slug="manufacturer-1")
    +        devicetype = DeviceType.objects.create(manufacturer=manufacturer, model="Device Type 1", slug="device-type-1")
    +        devicerole = DeviceRole.objects.create(name="Device Role 1", slug="device-role-1")
    +        site = Site.objects.first()
    +        Device.objects.create(name="Device 1", device_type=devicetype, device_role=devicerole, site=site)
    +        Device.objects.create(name="Device 2", device_type=devicetype, device_role=devicerole, site=site)
    +
             cls.form_data = {
                 "name": "new_dynamic_group",
                 "slug": "new-dynamic-group",
    @@ -628,6 +637,49 @@ def setUpTestData(cls):
                 "dynamic_group_memberships-MAX_NUM_FORMS": "1000",
             }
     
    +    def test_get_object_dynamic_groups_anonymous(self):
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
    +        self.client.logout()
    +        response = self.client.get(url, follow=True)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +
    +    def test_get_object_dynamic_groups_without_permission(self):
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, [403, 404])
    +
    +    def test_get_object_dynamic_groups_with_permission(self):
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
    +        self.add_permissions("dcim.view_device", "extras.view_dynamicgroup")
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, 200)
    +        response_body = response.content.decode(response.charset)
    +        self.assertIn("DG 1", response_body, msg=response_body)
    +        self.assertIn("DG 2", response_body, msg=response_body)
    +        self.assertIn("DG 3", response_body, msg=response_body)
    +
    +    def test_get_object_dynamic_groups_with_constrained_permission(self):
    +        self.add_permissions("extras.view_dynamicgroup")
    +        obj_perm = ObjectPermission(
    +            name="View a device",
    +            constraints={"pk": Device.objects.first().pk},
    +            actions=["view"],
    +        )
    +        obj_perm.save()
    +        obj_perm.users.add(self.user)
    +        obj_perm.object_types.add(ContentType.objects.get_for_model(Device))
    +
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, 200)
    +        response_body = response.content.decode(response.charset)
    +        self.assertIn("DG 1", response_body, msg=response_body)
    +
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.last().pk})
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, 404)
    +
         @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
         def test_edit_saved_filter(self):
             """Test that editing a filter works using the edit view."""
    @@ -760,6 +812,34 @@ def setUpTestData(cls):
             cls.slug_source = "name"
             cls.slug_test_object = "Repo 4"
     
    +    def test_post_sync_repo_anonymous(self):
    +        self.client.logout()
    +        url = reverse("extras:gitrepository_sync", kwargs={"slug": self._get_queryset().first().slug})
    +        response = self.client.post(url, follow=True)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +
    +    def test_post_sync_repo_without_permission(self):
    +        url = reverse("extras:gitrepository_sync", kwargs={"slug": self._get_queryset().first().slug})
    +        response = self.client.post(url)
    +        self.assertHttpStatus(response, [403, 404])
    +
    +    # TODO: mock/stub out `enqueue_pull_git_repository_and_refresh_data` and test successful POST with permissions
    +
    +    def test_post_dryrun_repo_anonymous(self):
    +        self.client.logout()
    +        url = reverse("extras:gitrepository_dryrun", kwargs={"slug": self._get_queryset().first().slug})
    +        response = self.client.post(url, follow=True)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +
    +    def test_post_dryrun_repo_without_permission(self):
    +        url = reverse("extras:gitrepository_dryrun", kwargs={"slug": self._get_queryset().first().slug})
    +        response = self.client.post(url)
    +        self.assertHttpStatus(response, [403, 404])
    +
    +    # TODO: mock/stub out `enqueue_git_repository_diff_origin_and_local` and test successful POST with permissions
    +
     
     class NoteTestCase(
         ViewTestCases.CreateObjectViewTestCase,
    @@ -1507,6 +1587,34 @@ def setUpTestData(cls):
                 job_id=uuid.uuid4(),
                 obj_type=obj_type,
             )
    +        JobLogEntry.objects.create(
    +            log_level=LogLevelChoices.LOG_INFO,
    +            job_result=JobResult.objects.first(),
    +            grouping="run",
    +            message="This is a test",
    +        )
    +
    +    def test_get_joblogentrytable_anonymous(self):
    +        url = reverse("extras:jobresult_log-table", kwargs={"pk": JobResult.objects.first().pk})
    +        self.client.logout()
    +        response = self.client.get(url, follow=True)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +
    +    def test_get_joblogentrytable_without_permission(self):
    +        url = reverse("extras:jobresult_log-table", kwargs={"pk": JobResult.objects.first().pk})
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, [403, 404])
    +
    +    def test_get_joblogentrytable_with_permission(self):
    +        url = reverse("extras:jobresult_log-table", kwargs={"pk": JobResult.objects.first().pk})
    +        self.add_permissions("extras.view_jobresult", "extras.view_joblogentry")
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, 200)
    +        response_body = response.content.decode(response.charset)
    +        self.assertIn("This is a test", response_body)
    +
    +    # TODO test with constrained permissions on both JobResult and JobLogEntry records
     
     
     class JobTestCase(
    
  • nautobot/extras/views.py+10 10 modified
    @@ -706,7 +706,7 @@ class DynamicGroupBulkDeleteView(generic.BulkDeleteView):
         table = tables.DynamicGroupTable
     
     
    -class ObjectDynamicGroupsView(View):
    +class ObjectDynamicGroupsView(generic.GenericView):
         """
         Present a list of dynamic groups associated to a particular object.
         base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
    @@ -894,18 +894,18 @@ def check_and_call_git_repository_function(request, slug, func):
         if not get_worker_count():
             messages.error(request, "Unable to run job: Celery worker process not running.")
         else:
    -        repository = get_object_or_404(GitRepository, slug=slug)
    +        repository = get_object_or_404(GitRepository.objects.restrict(request.user, "change"), slug=slug)
             func(repository, request)
     
         return redirect("extras:gitrepository_result", slug=slug)
     
     
    -class GitRepositorySyncView(View):
    +class GitRepositorySyncView(generic.GenericView):
         def post(self, request, slug):
             return check_and_call_git_repository_function(request, slug, enqueue_pull_git_repository_and_refresh_data)
     
     
    -class GitRepositoryDryRunView(View):
    +class GitRepositoryDryRunView(generic.GenericView):
         def post(self, request, slug):
             return check_and_call_git_repository_function(request, slug, enqueue_git_repository_diff_origin_and_local)
     
    @@ -1582,15 +1582,15 @@ def get_extra_context(self, request, instance):
             }
     
     
    -class JobLogEntryTableView(View):
    +class JobLogEntryTableView(generic.GenericView):
         """
         Display a table of `JobLogEntry` objects for a given `JobResult` instance.
         """
     
         queryset = JobResult.objects.all()
     
         def get(self, request, pk=None):
    -        instance = self.queryset.get(pk=pk)
    +        instance = get_object_or_404(self.queryset.restrict(request.user, "view"), pk=pk)
             log_table = tables.JobLogEntryTable(data=instance.logs.all(), user=request.user)
             RequestConfig(request).configure(log_table)
             return HttpResponse(log_table.as_html(request))
    @@ -1663,7 +1663,7 @@ def get_extra_context(self, request, instance):
             }
     
     
    -class ObjectChangeLogView(View):
    +class ObjectChangeLogView(generic.GenericView):
         """
         Present a history of changes made to a particular object.
         base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
    @@ -1735,7 +1735,7 @@ class NoteDeleteView(generic.ObjectDeleteView):
         queryset = Note.objects.all()
     
     
    -class ObjectNotesView(View):
    +class ObjectNotesView(generic.GenericView):
         """
         Present a history of changes made to a particular object.
         base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
    @@ -1756,7 +1756,7 @@ def get(self, request, model, **kwargs):
                     "assigned_object_id": obj.pk,
                 }
             )
    -        notes_table = tables.NoteTable(obj.notes)
    +        notes_table = tables.NoteTable(obj.notes.restrict(request.user, "view"))
     
             # Apply the request context
             paginate = {
    @@ -1869,7 +1869,7 @@ def get_extra_context(self, request, instance):
             }
     
     
    -class SecretProviderParametersFormView(View):
    +class SecretProviderParametersFormView(generic.GenericView):
         """
         Helper view to SecretView; retrieve the HTML form appropriate for entering parameters for a given SecretsProvider.
         """
    
  • nautobot/ipam/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     from . import views
     
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.IPAMRootView
    +router = OrderedDefaultRouter(view_name="IPAM")
     
     # VRFs
     router.register("vrfs", views.VRFViewSet)
    
  • nautobot/ipam/api/views.py+0 11 modified
    @@ -6,7 +6,6 @@
     from rest_framework.decorators import action
     from rest_framework.exceptions import APIException
     from rest_framework.response import Response
    -from rest_framework.routers import APIRootView
     
     from nautobot.extras.api.views import NautobotModelViewSet, StatusViewSetMixin
     from nautobot.ipam import filters
    @@ -30,16 +29,6 @@
     )
     from . import serializers
     
    -
    -class IPAMRootView(APIRootView):
    -    """
    -    IPAM API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "IPAM"
    -
    -
     #
     # VRFs
     #
    
  • nautobot/ipam/tests/test_graphql.py+2 3 modified
    @@ -1,4 +1,3 @@
    -from django.test import override_settings
     from django.urls import reverse
     from rest_framework import status
     
    @@ -14,8 +13,8 @@ def setUp(self):
             self.statuses = Status.objects.get_for_model(Prefix)
             self.prefixv4 = Prefix.objects.ip_family(4).first()
             self.prefixv6 = Prefix.objects.ip_family(6).first()
    +        self.add_permissions("ipam.view_prefix")
     
    -    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
         def test_prefix_family(self):
             """Test family is available for a Prefix via GraphQL."""
             get_prefixes_query = """
    @@ -28,7 +27,7 @@ def test_prefix_family(self):
             }
             """
             payload = {"query": get_prefixes_query}
    -        response = self.client.post(self.api_url, payload, format="json")
    +        response = self.client.post(self.api_url, payload, format="json", **self.header)
             self.assertEqual(response.status_code, status.HTTP_200_OK)
             prefixes = response.data["data"]["prefixes"]
             self.assertIsInstance(prefixes, list)
    
  • nautobot/ipam/views.py+4 3 modified
    @@ -696,9 +696,10 @@ class IPAddressAssignView(generic.ObjectView):
         queryset = IPAddress.objects.all()
     
         def dispatch(self, request, *args, **kwargs):
    -        # Redirect user if an interface has not been provided
    -        if "interface" not in request.GET and "vminterface" not in request.GET:
    -            return redirect("ipam:ipaddress_add")
    +        if request.user.is_authenticated:
    +            # Redirect user if an interface has not been provided
    +            if "interface" not in request.GET and "vminterface" not in request.GET:
    +                return redirect("ipam:ipaddress_add")
     
             return super().dispatch(request, *args, **kwargs)
     
    
  • nautobot/tenancy/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     from . import views
     
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.TenancyRootView
    +router = OrderedDefaultRouter(view_name="Tenancy")
     
     # Tenants
     router.register("tenant-groups", views.TenantGroupViewSet)
    
  • nautobot/tenancy/api/views.py+0 12 modified
    @@ -1,5 +1,3 @@
    -from rest_framework.routers import APIRootView
    -
     from nautobot.circuits.models import Circuit
     from nautobot.dcim.models import Device, Rack, Site
     from nautobot.extras.api.views import NautobotModelViewSet
    @@ -10,16 +8,6 @@
     from nautobot.virtualization.models import VirtualMachine
     from . import serializers
     
    -
    -class TenancyRootView(APIRootView):
    -    """
    -    Tenancy API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Tenancy"
    -
    -
     #
     # Tenant Groups
     #
    
  • nautobot/users/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     from . import views
     
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.UsersRootView
    +router = OrderedDefaultRouter(view_name="Users")
     
     # Users and groups
     router.register("users", views.UserViewSet)
    
  • nautobot/users/api/views.py+0 11 modified
    @@ -5,7 +5,6 @@
     from rest_framework.authentication import BasicAuthentication
     from rest_framework.permissions import IsAuthenticated
     from rest_framework.response import Response
    -from rest_framework.routers import APIRootView
     from rest_framework.viewsets import ViewSet
     
     from nautobot.core.api.serializers import BulkOperationIntegerIDSerializer
    @@ -16,16 +15,6 @@
     from nautobot.utilities.utils import deepmerge
     from . import serializers
     
    -
    -class UsersRootView(APIRootView):
    -    """
    -    Users API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Users"
    -
    -
     #
     # Users and groups
     #
    
  • nautobot/users/views.py+9 8 modified
    @@ -7,7 +7,6 @@
         logout as auth_logout,
         update_session_auth_hash,
     )
    -from django.contrib.auth.mixins import LoginRequiredMixin
     from django.http import HttpResponseForbidden, HttpResponseRedirect
     from django.shortcuts import get_object_or_404, redirect, render
     from django.urls import reverse
    @@ -16,7 +15,9 @@
     from django.views.decorators.debug import sensitive_post_parameters
     from django.views.generic import View
     
    +from nautobot.core.views.generic import GenericView
     from nautobot.utilities.forms import ConfirmationForm
    +
     from .forms import AdvancedProfileSettingsForm, LoginForm, PasswordChangeForm, TokenForm
     from .models import Token
     
    @@ -116,7 +117,7 @@ def is_django_auth_user(request):
         return request.session.get(BACKEND_SESSION_KEY, None) == "nautobot.core.authentication.ObjectPermissionBackend"
     
     
    -class ProfileView(LoginRequiredMixin, View):
    +class ProfileView(GenericView):
         template_name = "users/profile.html"
     
         def get(self, request):
    @@ -130,7 +131,7 @@ def get(self, request):
             )
     
     
    -class UserConfigView(LoginRequiredMixin, View):
    +class UserConfigView(GenericView):
         template_name = "users/preferences.html"
     
         def get(self, request):
    @@ -158,7 +159,7 @@ def post(self, request):
             return redirect("user:preferences")
     
     
    -class ChangePasswordView(LoginRequiredMixin, View):
    +class ChangePasswordView(GenericView):
         template_name = "users/change_password.html"
     
         RESTRICTED_NOTICE = "Remotely authenticated user credentials cannot be changed within Nautobot."
    @@ -216,7 +217,7 @@ def post(self, request):
     #
     
     
    -class TokenListView(LoginRequiredMixin, View):
    +class TokenListView(GenericView):
         def get(self, request):
             tokens = Token.objects.filter(user=request.user)
     
    @@ -231,7 +232,7 @@ def get(self, request):
             )
     
     
    -class TokenEditView(LoginRequiredMixin, View):
    +class TokenEditView(GenericView):
         def get(self, request, pk=None):
             if pk is not None:
                 if not request.user.has_perm("users.change_token"):
    @@ -290,7 +291,7 @@ def post(self, request, pk=None):
             )
     
     
    -class TokenDeleteView(LoginRequiredMixin, View):
    +class TokenDeleteView(GenericView):
         def get(self, request, pk):
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
             initial_data = {
    @@ -334,7 +335,7 @@ def post(self, request, pk):
     #
     
     
    -class AdvancedProfileSettingsEditView(LoginRequiredMixin, View):
    +class AdvancedProfileSettingsEditView(GenericView):
         template_name = "users/advanced_settings_edit.html"
     
         def get(self, request):
    
  • nautobot/utilities/api.py+4 0 modified
    @@ -47,6 +47,10 @@ def get_view_name(view, suffix=None):
     
         else:
             # Replicate DRF's built-in behavior.
    +        name = getattr(view, "name", None)
    +        if name is not None:
    +            return view.name
    +
             name = view.__class__.__name__
             name = formatting.remove_trailing_string(name, "View")
             name = formatting.remove_trailing_string(name, "ViewSet")
    
  • nautobot/utilities/tests/test_api.py+5 6 modified
    @@ -1,13 +1,12 @@
     from django.contrib.contenttypes.models import ContentType
    -from django.test import TestCase
     from django.urls import reverse
     from rest_framework import status
     
     from nautobot.dcim.models import Region, Site
     from nautobot.extras.choices import CustomFieldTypeChoices
     from nautobot.extras.models import CustomField
     from nautobot.ipam.models import VLAN
    -from nautobot.utilities.testing import APITestCase, NautobotTestClient, disable_warnings
    +from nautobot.utilities.testing import APITestCase, disable_warnings
     
     
     class WritableNestedSerializerTest(APITestCase):
    @@ -122,10 +121,9 @@ def test_related_by_invalid(self):
             self.assertEqual(VLAN.objects.filter(name="Test VLAN 100").count(), 0)
     
     
    -class APIDocsTestCase(TestCase):
    -    client_class = NautobotTestClient
    -
    +class APIDocsTestCase(APITestCase):
         def setUp(self):
    +        super().setUp()
             # Populate a CustomField to activate CustomFieldSerializer
             content_type = ContentType.objects.get_for_model(Site)
             self.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name="test")
    @@ -135,10 +133,11 @@ def setUp(self):
     
         def test_api_docs(self):
             url = reverse("api_docs")
    -        response = self.client.get(url)
    +        response = self.client.get(url, **self.header)
             self.assertEqual(response.status_code, 200)
     
             headers = {
    +            **self.header,
                 "HTTP_ACCEPT": "application/vnd.oai.openapi",
             }
             url = reverse("schema")
    
  • nautobot/utilities/utils.py+123 0 modified
    @@ -20,6 +20,7 @@
     from django.db.models.functions import Coalesce
     from django.http import QueryDict
     from django.template import engines
    +from django.urls import get_resolver, URLPattern, URLResolver
     from django.utils.functional import SimpleLazyObject
     from django.utils.module_loading import import_string
     from django.utils.text import slugify
    @@ -692,6 +693,128 @@ def get_table_for_model(model):
         return get_related_class_for_model(model, module_name="tables", object_suffix="Table")
     
     
    +def get_url_patterns(urlconf=None, patterns_list=None, base_path="/"):
    +    """
    +    Recursively yield a list of registered URL patterns.
    +
    +    Args:
    +        urlconf (URLConf): Python module such as `nautobot.core.urls`.
    +            Default if unspecified is the value of `settings.ROOT_URLCONF`, i.e. the `nautobot.core.urls` module.
    +        patterns_list (list): Used in recursion. Generally can be omitted on initial call.
    +            Default if unspecified is the `url_patterns` attribute of the given `urlconf` module.
    +        base_path (str): String to prepend to all URL patterns yielded.
    +            Default if unspecified is the string `"/"`.
    +
    +    Yields:
    +        (str): Each URL pattern defined in the given urlconf and its descendants
    +
    +    Examples:
    +        >>> generator = get_url_patterns()
    +        >>> next(generator)
    +        '/'
    +        >>> next(generator)
    +        '/search/'
    +        >>> next(generator)
    +        '/login/'
    +        >>> next(generator)
    +        '/logout/'
    +        >>> next(generator)
    +        '/circuits/circuits/<uuid:pk>/terminations/swap/'
    +
    +        >>> import example_plugin.urls as example_urls
    +        >>> for url_pattern in get_url_patterns(example_urls, base_path="/plugins/example-plugin/"):
    +        ...     print(url_pattern)
    +        ...
    +        /plugins/example-plugin/
    +        /plugins/example-plugin/config/
    +        /plugins/example-plugin/models/<uuid:pk>/dynamic-groups/
    +        /plugins/example-plugin/other-models/<uuid:pk>/dynamic-groups/
    +        /plugins/example-plugin/docs/
    +        /plugins/example-plugin/circuits/<uuid:pk>/example-plugin-tab/
    +        /plugins/example-plugin/devices/<uuid:pk>/example-plugin-tab-1/
    +        /plugins/example-plugin/devices/<uuid:pk>/example-plugin-tab-2/
    +        /plugins/example-plugin/override-target/
    +        /plugins/example-plugin/^models/$
    +        /plugins/example-plugin/^models/add/$
    +        /plugins/example-plugin/^models/import/$
    +        /plugins/example-plugin/^models/edit/$
    +        /plugins/example-plugin/^models/delete/$
    +        /plugins/example-plugin/^models/all-names/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/delete/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/edit/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/changelog/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/notes/$
    +        /plugins/example-plugin/^other-models/$
    +        /plugins/example-plugin/^other-models/add/$
    +        /plugins/example-plugin/^other-models/edit/$
    +        /plugins/example-plugin/^other-models/delete/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/delete/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/edit/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/changelog/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/notes/$
    +    """
    +    if urlconf is None:
    +        urlconf = settings.ROOT_URLCONF
    +    if patterns_list is None:
    +        patterns_list = get_resolver(urlconf).url_patterns
    +
    +    for item in patterns_list:
    +        if isinstance(item, URLPattern):
    +            yield base_path + str(item.pattern)
    +        elif isinstance(item, URLResolver):
    +            # Recurse!
    +            yield from get_url_patterns(urlconf, item.url_patterns, base_path + str(item.pattern))
    +
    +
    +def get_url_for_url_pattern(url_pattern):
    +    """
    +    Given a URL pattern, construct a URL string that would match that pattern.
    +
    +    Examples:
    +        >>> get_url_for_url_pattern("/plugins/example-plugin/^models/(?P<pk>[^/.]+)/$")
    +        '/plugins/example-plugin/models/00000000-0000-0000-0000-000000000000/'
    +        >>> get_url_for_url_pattern("/circuits/circuit-terminations/<uuid:termination_a_id>/connect/<str:termination_b_type>/")
    +        '/circuits/circuit-terminations/00000000-0000-0000-0000-000000000000/connect/string/'
    +    """
    +    url = url_pattern
    +    # Fixup tokens in path-style "classic" view URLs:
    +    # "/admin/users/user/<id>/password/"
    +    url = re.sub(r"<id>", "00000000-0000-0000-0000-000000000000", url)
    +    # "/silk/request/<uuid:request_id>/profile/<int:profile_id>/"
    +    url = re.sub(r"<int:\w+>", "1", url)
    +    # "/admin/admin/logentry/<path:object_id>/"
    +    url = re.sub(r"<path:\w+>", "1", url)
    +    # "/dcim/sites/<slug:slug>/"
    +    url = re.sub(r"<slug:\w+>", "slug", url)
    +    # "/apps/installed-apps/<str:app>/"
    +    url = re.sub(r"<str:\w+>", "string", url)
    +    # "/dcim/locations/<uuid:pk>/"
    +    url = re.sub(r"<uuid:\w+>", "00000000-0000-0000-0000-000000000000", url)
    +    # tokens in regexp-style router urls, including REST and NautobotUIViewSet:
    +    # "/extras/^external-integrations/(?P<pk>[^/.]+)/$"
    +    # "/api/virtualization/^interfaces/(?P<pk>[^/.]+)/$"
    +    # "/api/virtualization/^interfaces/(?P<pk>[^/.]+)\\.(?P<format>[a-z0-9]+)/?$"
    +    url = re.sub(r"[$^]", "", url)
    +    url = re.sub(r"/\?", "/", url)
    +    url = re.sub(r"\(\?P<app_label>[^)]+\)", "users", url)
    +    url = re.sub(r"\(\?P<class_path>[^)]+\)", "foo/bar/baz", url)
    +    url = re.sub(r"\(\?P<format>[^)]+\)", "json", url)
    +    url = re.sub(r"\(\?P<name>[^)]+\)", "string", url)
    +    url = re.sub(r"\(\?P<pk>[^)]+\)", "00000000-0000-0000-0000-000000000000", url)
    +    url = re.sub(r"\(\?P<slug>[^)]+\)", "string", url)
    +    url = re.sub(r"\(\?P<url>[^)]+\)", "any", url)
    +    # Fallthru for generic URL parameters
    +    url = re.sub(r"\(\?P<\w+>[^)]+\)\??", "unknown", url)
    +    url = re.sub(r"\\", "", url)
    +
    +    if any(char in url for char in "<>[]()?+^$"):
    +        raise RuntimeError(f"Unhandled token in URL {url} derived from {url_pattern}")
    +
    +    return url
    +
    +
     # Setup UtilizationData named tuple for use by multiple methods
     UtilizationData = namedtuple("UtilizationData", ["numerator", "denominator"])
     
    
  • nautobot/virtualization/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     from . import views
     
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.VirtualizationRootView
    +router = OrderedDefaultRouter(view_name="Virtualization")
     
     # Clusters
     router.register("cluster-types", views.ClusterTypeViewSet)
    
  • nautobot/virtualization/api/views.py+0 11 modified
    @@ -1,5 +1,4 @@
     from drf_spectacular.utils import extend_schema, extend_schema_view
    -from rest_framework.routers import APIRootView
     
     from nautobot.dcim.models import Device
     from nautobot.extras.api.views import (
    @@ -20,16 +19,6 @@
     )
     from . import serializers
     
    -
    -class VirtualizationRootView(APIRootView):
    -    """
    -    Virtualization API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Virtualization"
    -
    -
     #
     # Clusters
     #
    
dd623e6c3307

View authentication and permission fixes (#5464)

https://github.com/nautobot/nautobotGlenn MatthewsMar 25, 2024via ghsa
47 files changed · +474 254
  • changes/5464.added+2 0 added
    @@ -0,0 +1,2 @@
    +Added `nautobot.apps.utils.get_url_for_url_pattern` and `nautobot.apps.utils.get_url_patterns` lookup functions.
    +Added `nautobot.apps.views.GenericView` base class.
    
  • changes/5464.changed+2 0 added
    @@ -0,0 +1,2 @@
    +Added support for `view_name` and `view_description` optional parameters when instantiating a `nautobot.apps.api.OrderedDefaultRouter`. Specifying these parameters is to be preferred over defining a custom `APIRootView` subclass when defining App API URLs.
    +Added requirement for user authentication by default on the `nautobot.apps.api.APIRootView` class. As a consequence, viewing the browsable REST API root endpoints (e.g. `/api/`, `/api/circuits/`, `/api/dcim/`, etc.) now requires user authentication.
    
  • changes/5464.documentation+1 0 added
    @@ -0,0 +1 @@
    +Updated example views in the App developer documentation to include `ObjectPermissionRequiredMixin` or `LoginRequiredMixin` as appropriate best practices.
    
  • changes/5464.fixed+1 0 added
    @@ -0,0 +1 @@
    +Fixed a 500 error when accessing any of the `/dcim/<port-type>/<uuid>/connect/<termination_b_type>/` view endpoints with an invalid/nonexistent `termination_b_type` string.
    
  • changes/5464.housekeeping+1 0 added
    @@ -0,0 +1 @@
    +Updated custom views in the `example_plugin` to use the new `GenericView` base class as a best practice.
    
  • changes/5464.removed+1 0 added
    @@ -0,0 +1 @@
    +Removed the URL endpoints `/api/users/users/my-profile/`, `/api/users/users/session/`, `/api/users/tokens/authenticate/`, and `/api/users/tokens/logout/` as they are unused at this time.
    
  • changes/5464.security+8 0 added
    @@ -0,0 +1,8 @@
    +Added requirement for user authentication to access the endpoint `/extras/job-results/<uuid:pk>/log-table/`; furthermore it will not allow an authenticated user to view log entries for a JobResult they don't otherwise have permission to view. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added narrower permissions enforcement on the endpoints `/extras/git-repositories/<uuid:pk>/sync/` and `/extras/git-repositories/<uuid:pk>/dry-run/`; a user who has `change` permissions for a subset of Git repositories is no longer permitted to sync or dry-run other repositories for which they lack the appropriate permissions. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added narrower permissions enforcement on the `/api/dcim/connected-device/?peer_device=...&?peer_interface=...` REST API endpoint; a user who has `view` permissions for a subset of interfaces is no longer permitted to query other interfaces for which they lack permissions. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added narrower permissions enforcement on all `<app>/<model>/<uuid>/notes/` UI endpoints; a user must now have the appropriate `extras.view_note` permissions to view existing notes. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added requirement for user authentication to access the REST API endpoints `/api/redoc/`, `/api/swagger/`, `/api/swagger.json`, and `/api/swagger.yaml`. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added requirement for user authentication to access the `/api/graphql` REST API endpoint, even when `EXEMPT_VIEW_PERMISSIONS` is configured. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added requirement for user authentication to access the endpoints `/dcim/racks/<uuid>/dynamic-groups/`, `/dcim/devices/<uuid>/dynamic-groups/`, `/ipam/prefixes/<uuid>/dynamic-groups/`, `/ipam/ip-addresses/<uuid>/dynamic-groups/`, `/virtualization/clusters/<uuid>/dynamic-groups/`, and `/virtualization/virtual-machines/<uuid>/dynamic-groups/`, even when `EXEMPT_VIEW_PERMISSIONS` is configured. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    +Added requirement for user authentication to access the endpoint `/extras/secrets/provider/<str:provider_slug>/form/`. ([GHSA-m732-wvh2-7cq4](https://github.com/nautobot/nautobot/security/advisories/GHSA-m732-wvh2-7cq4))
    
  • examples/example_plugin/example_plugin/api/urls.py+1 1 modified
    @@ -4,7 +4,7 @@
     
     from example_plugin.api.views import AnotherExampleModelViewSet, ExampleModelViewSet, ExampleModelWebhook
     
    -router = OrderedDefaultRouter()
    +router = OrderedDefaultRouter(view_name="Example App")
     router.register("models", ExampleModelViewSet)
     router.register("other-models", AnotherExampleModelViewSet)
     
    
  • examples/example_plugin/example_plugin/views.py+3 4 modified
    @@ -1,5 +1,4 @@
     from django.shortcuts import HttpResponse, render
    -from django.views.generic import View
     from rest_framework.decorators import action
     
     from nautobot.apps import views
    @@ -46,12 +45,12 @@ class DeviceDetailPluginTabTwoView(views.ObjectView):
         template_name = "example_plugin/tab_device_detail_2.html"
     
     
    -class ExamplePluginHomeView(View):
    +class ExamplePluginHomeView(views.GenericView):
         def get(self, request):
             return render(request, "example_plugin/home.html")
     
     
    -class ExamplePluginConfigView(View):
    +class ExamplePluginConfigView(views.GenericView):
         def get(self, request):
             """Render the configuration page for this plugin.
     
    @@ -114,6 +113,6 @@ class AnotherExampleModelUIViewSet(
         table_class = tables.AnotherExampleModelTable
     
     
    -class ViewToBeOverridden(View):
    +class ViewToBeOverridden(views.GenericView):
         def get(self, request, *args, **kwargs):
             return HttpResponse("I am a view in the example plugin which will be overridden by another plugin.")
    
  • examples/example_plugin_with_view_override/example_plugin_with_view_override/views.py+3 2 modified
    @@ -1,10 +1,11 @@
     """Views for plugin_with_view_override."""
     
     from django.shortcuts import HttpResponse
    -from django.views import generic
     
    +from nautobot.apps.views import GenericView
     
    -class ViewOverride(generic.View):
    +
    +class ViewOverride(GenericView):
         def get(self, request, *args, **kwargs):
             return HttpResponse("Hello world! I'm an overridden view.")
     
    
  • nautobot/apps/api.py+1 2 modified
    @@ -19,7 +19,7 @@
     )
     from nautobot.core.api.mixins import WritableSerializerMixin
     from nautobot.core.api.parsers import NautobotCSVParser
    -from nautobot.core.api.routers import OrderedDefaultRouter
    +from nautobot.core.api.routers import AuthenticatedAPIRootView as APIRootView, OrderedDefaultRouter
     from nautobot.core.api.schema import NautobotAutoSchema
     from nautobot.core.api.serializers import (
         OptInFieldsMixin,
    @@ -36,7 +36,6 @@
         versioned_serializer_selector,
     )
     from nautobot.core.api.views import (
    -    APIRootView,
         BulkDestroyModelMixin,
         BulkUpdateModelMixin,
         GetObjectCountsView,
    
  • nautobot/apps/utils.py+4 0 modified
    @@ -32,6 +32,8 @@
         get_related_class_for_model,
         get_route_for_model,
         get_table_for_model,
    +    get_url_for_url_pattern,
    +    get_url_patterns,
     )
     from nautobot.core.utils.navigation import (
         get_all_new_ui_ready_routes,
    @@ -110,6 +112,8 @@
         "get_route_for_model",
         "get_settings_or_config",
         "get_table_for_model",
    +    "get_url_for_url_pattern",
    +    "get_url_patterns",
         "get_worker_count",
         "GitRepo",
         "hex_to_rgb",
    
  • nautobot/apps/views.py+2 0 modified
    @@ -8,6 +8,7 @@
         BulkImportView,
         BulkRenameView,
         ComponentCreateView,
    +    GenericView,
         ObjectDeleteView,
         ObjectEditView,
         ObjectImportView,
    @@ -57,6 +58,7 @@
         "csv_format",
         "EnhancedPage",
         "EnhancedPaginator",
    +    "GenericView",
         "get_csv_form_fields_from_serializer_class",
         "get_paginate_count",
         "GetReturnURLMixin",
    
  • nautobot/circuits/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     
     from . import views
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.CircuitsRootView
    +router = OrderedDefaultRouter(view_name="Circuits")
     
     # Providers
     router.register("providers", views.ProviderViewSet)
    
  • nautobot/circuits/api/views.py+0 12 modified
    @@ -1,5 +1,3 @@
    -from rest_framework.routers import APIRootView
    -
     from nautobot.circuits import filters
     from nautobot.circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
     from nautobot.core.models.querysets import count_related
    @@ -8,16 +6,6 @@
     
     from . import serializers
     
    -
    -class CircuitsRootView(APIRootView):
    -    """
    -    Circuits API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Circuits"
    -
    -
     #
     # Providers
     #
    
  • nautobot/core/api/routers.py+25 1 modified
    @@ -1,10 +1,23 @@
    +import logging
    +
     from rest_framework.routers import DefaultRouter
     
    +from nautobot.core.api.views import AuthenticatedAPIRootView
    +
    +logger = logging.getLogger(__name__)
    +
     
     class OrderedDefaultRouter(DefaultRouter):
    -    def __init__(self, *args, **kwargs):
    +    APIRootView = AuthenticatedAPIRootView
    +
    +    def __init__(self, *args, view_name=None, view_description=None, **kwargs):
             super().__init__(*args, **kwargs)
     
    +        self.view_name = view_name
    +        if view_name and not view_description:
    +            view_description = f"{view_name} API root view"
    +        self.view_description = view_description
    +
             # Extend the list view mappings to support the DELETE operation
             self.routes[0].mapping.update(
                 {
    @@ -20,7 +33,18 @@ def get_api_root_view(self, api_urls=None):
             """
             api_root_dict = {}
             list_name = self.routes[0].name
    +
             for prefix, _viewset, basename in sorted(self.registry, key=lambda x: x[0]):
                 api_root_dict[prefix] = list_name.format(basename=basename)
     
    +        if issubclass(self.APIRootView, AuthenticatedAPIRootView):
    +            return self.APIRootView.as_view(
    +                api_root_dict=api_root_dict, name=self.view_name, description=self.view_description
    +            )
    +        # Fallback for the established practice of overriding self.APIRootView with a custom class
    +        logger.warning(
    +            "Something has changed an OrderedDefaultRouter's APIRootView attribute to a custom class. "
    +            "Please verify that class %s implements appropriate authentication controls.",
    +            self.APIRootView.__name__,
    +        )
             return self.APIRootView.as_view(api_root_dict=api_root_dict)
    
  • nautobot/core/api/utils.py+4 0 modified
    @@ -178,6 +178,10 @@ def get_view_name(view, suffix=None):
     
         else:
             # Replicate DRF's built-in behavior.
    +        name = getattr(view, "name", None)
    +        if name is not None:
    +            return view.name
    +
             name = view.__class__.__name__
             name = formatting.remove_trailing_string(name, "View")
             name = formatting.remove_trailing_string(name, "ViewSet")
    
  • nautobot/core/api/views.py+21 15 modified
    @@ -22,9 +22,9 @@
     from graphql.execution import ExecutionResult
     from graphql.execution.middleware import MiddlewareManager
     from graphql.type.schema import GraphQLSchema
    -from rest_framework import status
    +from rest_framework import routers, status
     from rest_framework.exceptions import ParseError, PermissionDenied
    -from rest_framework.permissions import AllowAny, IsAuthenticated
    +from rest_framework.permissions import IsAuthenticated
     from rest_framework.response import Response
     from rest_framework.reverse import reverse
     from rest_framework.views import APIView
    @@ -341,15 +341,25 @@ class ReadOnlyModelViewSet(NautobotAPIVersionMixin, ModelViewSetMixin, ReadOnlyM
     #
     
     
    -class APIRootView(NautobotAPIVersionMixin, APIView):
    +class AuthenticatedAPIRootView(NautobotAPIVersionMixin, routers.APIRootView):
         """
    -    This is the root of the REST API. API endpoints are arranged by app and model name; e.g. `/api/dcim/locations/`.
    +    Extends DRF's base APIRootView class to enforce user authentication.
         """
     
    -    _ignore_model_permissions = True
    +    permission_classes = [IsAuthenticated]
    +
    +    name = None
    +    description = None
    +
    +
    +class APIRootView(AuthenticatedAPIRootView):
    +    """
    +    This is the root of the REST API.
     
    -    def get_view_name(self):
    -        return "API Root"
    +    API endpoints are arranged by app and model name; e.g. `/api/dcim/locations/`.
    +    """
    +
    +    name = "API Root"
     
         @extend_schema(exclude=True)
         def get(self, request, format=None):  # pylint: disable=redefined-builtin
    @@ -492,11 +502,6 @@ class FakeOpenAPIRenderer(OpenApiJsonRenderer):
         @extend_schema(exclude=True)
         def get(self, request, *args, **kwargs):
             """Fix up the rendering of the Swagger UI to work with Nautobot's UI."""
    -        if not request.user.is_authenticated:
    -            doc_url = reverse("api_docs")
    -            login_url = reverse(settings.LOGIN_URL)
    -            return redirect(f"{login_url}?next={doc_url}")
    -
             # For backward compatibility wtih drf-yasg, `/api/docs/?format=openapi` is a redirect to the JSON schema.
             if request.GET.get("format") == "openapi":
                 return redirect("schema_json", permanent=True)
    @@ -525,11 +530,12 @@ class NautobotSpectacularRedocView(APIVersioningGetSchemaURLMixin, SpectacularRe
     class GraphQLDRFAPIView(NautobotAPIVersionMixin, APIView):
         """
         API View for GraphQL to integrate properly with DRF authentication mechanism.
    -    The code is a stripped down version of graphene-django default View
    -    https://github.com/graphql-python/graphene-django/blob/main/graphene_django/views.py#L57
         """
     
    -    permission_classes = [AllowAny]
    +    # The code is a stripped down version of graphene-django default View
    +    # https://github.com/graphql-python/graphene-django/blob/main/graphene_django/views.py#L57
    +
    +    permission_classes = [IsAuthenticated]
         graphql_schema = None
         executor = None
         backend = None
    
  • nautobot/core/settings.py+1 0 modified
    @@ -242,6 +242,7 @@
         # trim it from all of the individual paths correspondingly.
         # See also https://github.com/nautobot/nautobot-ansible/pull/135 for an example of why this is desirable.
         "SERVERS": [{"url": "/api"}],
    +    "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAuthenticated"],
         "SCHEMA_PATH_PREFIX": "/api",
         "SCHEMA_PATH_PREFIX_TRIM": True,
         # use sidecar - locally packaged UI files, not CDN
    
  • nautobot/core/tests/integration/test_view_authentication.py+67 0 added
    @@ -0,0 +1,67 @@
    +from django.test import tag
    +
    +from nautobot.core.testing import TestCase
    +from nautobot.core.utils.lookup import get_url_for_url_pattern, get_url_patterns
    +
    +
    +@tag("integration")
    +class AuthenticationEnforcedTestCase(TestCase):
    +    r"""
    +    Test that all\* registered views require authentication to access.
    +
    +    \* with a very small number of known exceptions such as login and logout views.
    +    """
    +
    +    def test_all_views_require_authentication(self):
    +        self.client.logout()
    +        url_patterns = get_url_patterns()
    +
    +        for url_pattern in url_patterns:
    +            with self.subTest(url_pattern=url_pattern):
    +                url = get_url_for_url_pattern(url_pattern)
    +                response = self.client.get(url, follow=True)
    +
    +                if response.status_code == 405:  # Method not allowed
    +                    response = self.client.post(url, follow=True)
    +
    +                # Is a view that *should* be open to unauthenticated users?
    +                if url in [
    +                    "/admin/login/",
    +                    "/api/plugins/example-plugin/webhook/",
    +                    "/health/",
    +                    "/login/",
    +                    "/media-failure/",
    +                    "/template.css",
    +                ]:
    +                    self.assertHttpStatus(response, 200, msg=url)
    +                elif response.status_code == 200:
    +                    # UI views generally should redirect unauthenticated users to the appropriate login page
    +                    if url.startswith("/admin"):
    +                        if "logout" in url:
    +                            # /admin/logout/ sets next=/admin/ because having login redirect to logout would be silly
    +                            redirect_url = "/admin/login/?next=/admin/"
    +                        else:
    +                            redirect_url = f"/admin/login/?next={url}"
    +                    else:
    +                        if "logout" in url:
    +                            # /logout/ sets next=/ because having login redirect back to logout would be silly
    +                            redirect_url = "/login/?next=/"
    +                        else:
    +                            redirect_url = f"/login/?next={url}"
    +                    self.assertRedirects(response, redirect_url)
    +                elif response.status_code != 403:
    +                    if any(
    +                        url.startswith(path)
    +                        for path in [
    +                            "/complete/",  # social auth
    +                            "/login/",  # social auth
    +                            "/media/",  # MEDIA_ROOT
    +                            "/plugins/example-plugin/docs/",  # STATIC_ROOT
    +                        ]
    +                    ):
    +                        self.assertEqual(response.status_code, 404)
    +                    else:
    +                        self.fail(
    +                            f"Unexpected {response.status_code} response at {url}: "
    +                            + response.content.decode(response.charset)
    +                        )
    
  • nautobot/core/tests/test_graphql.py+2 14 modified
    @@ -630,21 +630,9 @@ def test_graphql_api_token_no_group_exempt(self):
             self.assertEqual(names, ["Rack 1-1", "Rack 1-2", "Rack 2-1", "Rack 2-2"])
     
         def test_graphql_api_no_token(self):
    -        """Validate unauthenticated users are not able to query anything by default."""
    +        """Validate unauthenticated users are not able to query anything."""
             response = self.client.post(self.api_url, {"query": self.get_racks_query}, format="json")
    -        self.assertEqual(response.status_code, status.HTTP_200_OK)
    -        self.assertIsInstance(response.data["data"]["racks"], list)
    -        names = [item["name"] for item in response.data["data"]["racks"]]
    -        self.assertEqual(names, [])
    -
    -    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
    -    def test_graphql_api_no_token_exempt(self):
    -        """Validate unauthenticated users are able to query based on the exempt permissions."""
    -        response = self.client.post(self.api_url, {"query": self.get_racks_query}, format="json")
    -        self.assertEqual(response.status_code, status.HTTP_200_OK)
    -        self.assertIsInstance(response.data["data"]["racks"], list)
    -        names = [item["name"] for item in response.data["data"]["racks"]]
    -        self.assertEqual(names, ["Rack 1-1", "Rack 1-2", "Rack 2-1", "Rack 2-2"])
    +        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
     
         def test_graphql_api_wrong_token(self):
             """Validate a wrong token return 403."""
    
  • nautobot/core/tests/test_views.py+22 16 modified
    @@ -315,25 +315,31 @@ def test_sso_login_button_visible(self):
             sso_login_search_result = self.make_request()
             self.assertIsNotNone(sso_login_search_result)
     
    -    @override_settings(BANNER_TOP="Hello, Banner Top", BANNER_BOTTOM="Hello, Banner Bottom")
    -    def test_routes_redirect_back_to_login_unauthenticated(self):
    -        """Assert that api docs and graphql redirects to login page if user is unauthenticated."""
    +    def test_graphql_redirects_back_to_login_unauthenticated(self):
    +        """Assert that graphql redirects to login page if user is unauthenticated."""
             self.client.logout()
             headers = {"HTTP_ACCEPT": "text/html"}
    -        urls = [reverse("api_docs"), reverse("graphql")]
    +        url = reverse("graphql")
    +        response = self.client.get(url, follow=True, **headers)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +        response_content = response.content.decode(response.charset).replace("\n", "")
    +        for footer_text in self.footer_elements:
    +            self.assertNotIn(footer_text, response_content)
    +
    +    def test_api_docs_403_unauthenticated(self):
    +        """Assert that api docs return a 403 Forbidden if user is unauthenticated."""
    +        self.client.logout()
    +        urls = [
    +            reverse("api_docs"),
    +            reverse("api_redocs"),
    +            reverse("schema"),
    +            reverse("schema_json"),
    +            reverse("schema_yaml"),
    +        ]
             for url in urls:
    -            response = self.client.get(url, follow=True, **headers)
    -            self.assertHttpStatus(response, 200)
    -            redirect_chain = [(f"/login/?next={url}", 302)]
    -            self.assertEqual(response.redirect_chain, redirect_chain)
    -            response_content = response.content.decode(response.charset).replace("\n", "")
    -            # Assert Footer items(`self.footer_elements`), Banner and Banner Top is hidden
    -            for footer_text in self.footer_elements:
    -                self.assertNotIn(footer_text, response_content)
    -            # Only API Docs implements BANNERS
    -            if url == urls[0]:
    -                self.assertNotIn("Hello, Banner Top", response_content)
    -                self.assertNotIn("Hello, Banner Bottom", response_content)
    +            response = self.client.get(url)
    +            self.assertHttpStatus(response, 403)
     
     
     class MetricsViewTestCase(TestCase):
    
  • nautobot/core/utils/lookup.py+124 0 modified
    @@ -1,12 +1,14 @@
     """Utilities for looking up related classes and information."""
     
     import inspect
    +import re
     
     from django.apps import apps
     from django.conf import settings
     from django.contrib.auth.models import Group
     from django.contrib.contenttypes.models import ContentType
     from django.db.models import Model
    +from django.urls import get_resolver, URLPattern, URLResolver
     from django.utils.module_loading import import_string
     
     
    @@ -218,3 +220,125 @@ def get_created_and_last_updated_usernames_for_model(instance):
             last_updated_by = last_updated_by_record.user_name
     
         return created_by, last_updated_by
    +
    +
    +def get_url_patterns(urlconf=None, patterns_list=None, base_path="/"):
    +    """
    +    Recursively yield a list of registered URL patterns.
    +
    +    Args:
    +        urlconf (URLConf): Python module such as `nautobot.core.urls`.
    +            Default if unspecified is the value of `settings.ROOT_URLCONF`, i.e. the `nautobot.core.urls` module.
    +        patterns_list (list): Used in recursion. Generally can be omitted on initial call.
    +            Default if unspecified is the `url_patterns` attribute of the given `urlconf` module.
    +        base_path (str): String to prepend to all URL patterns yielded.
    +            Default if unspecified is the string `"/"`.
    +
    +    Yields:
    +        (str): Each URL pattern defined in the given urlconf and its descendants
    +
    +    Examples:
    +        >>> generator = get_url_patterns()
    +        >>> next(generator)
    +        '/'
    +        >>> next(generator)
    +        '/search/'
    +        >>> next(generator)
    +        '/login/'
    +        >>> next(generator)
    +        '/logout/'
    +        >>> next(generator)
    +        '/circuits/circuits/<uuid:pk>/terminations/swap/'
    +
    +        >>> import example_plugin.urls as example_urls
    +        >>> for url_pattern in get_url_patterns(example_urls, base_path="/plugins/example-plugin/"):
    +        ...     print(url_pattern)
    +        ...
    +        /plugins/example-plugin/
    +        /plugins/example-plugin/config/
    +        /plugins/example-plugin/models/<uuid:pk>/dynamic-groups/
    +        /plugins/example-plugin/other-models/<uuid:pk>/dynamic-groups/
    +        /plugins/example-plugin/docs/
    +        /plugins/example-plugin/circuits/<uuid:pk>/example-plugin-tab/
    +        /plugins/example-plugin/devices/<uuid:pk>/example-plugin-tab-1/
    +        /plugins/example-plugin/devices/<uuid:pk>/example-plugin-tab-2/
    +        /plugins/example-plugin/override-target/
    +        /plugins/example-plugin/^models/$
    +        /plugins/example-plugin/^models/add/$
    +        /plugins/example-plugin/^models/import/$
    +        /plugins/example-plugin/^models/edit/$
    +        /plugins/example-plugin/^models/delete/$
    +        /plugins/example-plugin/^models/all-names/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/delete/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/edit/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/changelog/$
    +        /plugins/example-plugin/^models/(?P<pk>[^/.]+)/notes/$
    +        /plugins/example-plugin/^other-models/$
    +        /plugins/example-plugin/^other-models/add/$
    +        /plugins/example-plugin/^other-models/edit/$
    +        /plugins/example-plugin/^other-models/delete/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/delete/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/edit/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/changelog/$
    +        /plugins/example-plugin/^other-models/(?P<pk>[^/.]+)/notes/$
    +    """
    +    if urlconf is None:
    +        urlconf = settings.ROOT_URLCONF
    +    if patterns_list is None:
    +        patterns_list = get_resolver(urlconf).url_patterns
    +
    +    for item in patterns_list:
    +        if isinstance(item, URLPattern):
    +            yield base_path + str(item.pattern)
    +        elif isinstance(item, URLResolver):
    +            # Recurse!
    +            yield from get_url_patterns(urlconf, item.url_patterns, base_path + str(item.pattern))
    +
    +
    +def get_url_for_url_pattern(url_pattern):
    +    """
    +    Given a URL pattern, construct a URL string that would match that pattern.
    +
    +    Examples:
    +        >>> get_url_for_url_pattern("/plugins/example-plugin/^models/(?P<pk>[^/.]+)/$")
    +        '/plugins/example-plugin/models/00000000-0000-0000-0000-000000000000/'
    +        >>> get_url_for_url_pattern("/circuits/circuit-terminations/<uuid:termination_a_id>/connect/<str:termination_b_type>/")
    +        '/circuits/circuit-terminations/00000000-0000-0000-0000-000000000000/connect/string/'
    +    """
    +    url = url_pattern
    +    # Fixup tokens in path-style "classic" view URLs:
    +    # "/admin/users/user/<id>/password/"
    +    url = re.sub(r"<id>", "00000000-0000-0000-0000-000000000000", url)
    +    # "/silk/request/<uuid:request_id>/profile/<int:profile_id>/"
    +    url = re.sub(r"<int:\w+>", "1", url)
    +    # "/admin/admin/logentry/<path:object_id>/"
    +    url = re.sub(r"<path:\w+>", "1", url)
    +    # "/dcim/sites/<slug:slug>/"
    +    url = re.sub(r"<slug:\w+>", "slug", url)
    +    # "/apps/installed-apps/<str:app>/"
    +    url = re.sub(r"<str:\w+>", "string", url)
    +    # "/dcim/locations/<uuid:pk>/"
    +    url = re.sub(r"<uuid:\w+>", "00000000-0000-0000-0000-000000000000", url)
    +    # tokens in regexp-style router urls, including REST and NautobotUIViewSet:
    +    # "/extras/^external-integrations/(?P<pk>[^/.]+)/$"
    +    # "/api/virtualization/^interfaces/(?P<pk>[^/.]+)/$"
    +    # "/api/virtualization/^interfaces/(?P<pk>[^/.]+)\\.(?P<format>[a-z0-9]+)/?$"
    +    url = re.sub(r"[$^]", "", url)
    +    url = re.sub(r"/\?", "/", url)
    +    url = re.sub(r"\(\?P<app_label>[^)]+\)", "users", url)
    +    url = re.sub(r"\(\?P<class_path>[^)]+\)", "foo/bar/baz", url)
    +    url = re.sub(r"\(\?P<format>[^)]+\)", "json", url)
    +    url = re.sub(r"\(\?P<name>[^)]+\)", "string", url)
    +    url = re.sub(r"\(\?P<pk>[^)]+\)", "00000000-0000-0000-0000-000000000000", url)
    +    url = re.sub(r"\(\?P<slug>[^)]+\)", "string", url)
    +    url = re.sub(r"\(\?P<url>[^)]+\)", "any", url)
    +    # Fallthru for generic URL parameters
    +    url = re.sub(r"\(\?P<\w+>[^)]+\)\??", "unknown", url)
    +    url = re.sub(r"\\", "", url)
    +
    +    if any(char in url for char in "<>[]()?+^$"):
    +        raise RuntimeError(f"Unhandled token in URL {url} derived from {url_pattern}")
    +
    +    return url
    
  • nautobot/core/views/generic.py+9 0 modified
    @@ -4,6 +4,7 @@
     
     from django.conf import settings
     from django.contrib import messages
    +from django.contrib.auth.mixins import LoginRequiredMixin
     from django.contrib.contenttypes.models import ContentType
     from django.core.exceptions import (
         FieldDoesNotExist,
    @@ -57,6 +58,14 @@
     from nautobot.extras.utils import remove_prefix_from_cf_key
     
     
    +class GenericView(LoginRequiredMixin, View):
    +    """
    +    Base class for non-object-related views.
    +
    +    Enforces authentication, which Django's base View does not by default.
    +    """
    +
    +
     class ObjectView(ObjectPermissionRequiredMixin, View):
         """
         Retrieve a single object for display.
    
  • nautobot/core/views/__init__.py+3 7 modified
    @@ -11,7 +11,7 @@
     from django.contrib.auth.mixins import AccessMixin, LoginRequiredMixin
     from django.contrib.contenttypes.models import ContentType
     from django.http import HttpResponseForbidden, HttpResponseServerError, JsonResponse
    -from django.shortcuts import get_object_or_404, redirect, render
    +from django.shortcuts import get_object_or_404, render
     from django.template import loader, RequestContext, Template
     from django.template.exceptions import TemplateDoesNotExist
     from django.urls import resolve, reverse
    @@ -210,7 +210,7 @@ def get(self, request):
             )
     
     
    -class StaticMediaFailureView(View):
    +class StaticMediaFailureView(View):  # NOT using LoginRequiredMixin here as this may happen even on the login page
         """
         Display a user-friendly error message with troubleshooting tips when a static media file fails to load.
         """
    @@ -265,12 +265,8 @@ def csrf_failure(request, reason="", template_name="403_csrf_failure.html"):
         return HttpResponseForbidden(t.render(context), content_type="text/html")
     
     
    -class CustomGraphQLView(GraphQLView):
    +class CustomGraphQLView(LoginRequiredMixin, GraphQLView):
         def render_graphiql(self, request, **data):
    -        if not request.user.is_authenticated:
    -            graphql_url = reverse("graphql")
    -            login_url = reverse(settings.LOGIN_URL)
    -            return redirect(f"{login_url}?next={graphql_url}")
             query_name = request.GET.get("name")
             if query_name:
                 data["obj"] = GraphQLQuery.objects.get(name=query_name)
    
  • nautobot/dcim/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     
     from . import views
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.DCIMRootView
    +router = OrderedDefaultRouter(view_name="DCIM")
     
     # Locations
     router.register("location-types", views.LocationTypeViewSet)
    
  • nautobot/dcim/api/views.py+1 12 modified
    @@ -13,7 +13,6 @@
     from rest_framework.mixins import ListModelMixin
     from rest_framework.permissions import IsAuthenticated
     from rest_framework.response import Response
    -from rest_framework.routers import APIRootView
     from rest_framework.viewsets import GenericViewSet, ViewSet
     
     from nautobot.circuits.models import Circuit
    @@ -69,16 +68,6 @@
     from . import serializers
     from .exceptions import MissingFilterException
     
    -
    -class DCIMRootView(APIRootView):
    -    """
    -    DCIM API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "DCIM"
    -
    -
     # Mixins
     
     
    @@ -762,7 +751,7 @@ def list(self, request):
     
             # Determine local interface from peer interface's connection
             peer_interface = get_object_or_404(
    -            Interface.objects.all(),
    +            Interface.objects.restrict(request.user, "view"),
                 device__name=peer_device_name,
                 name=peer_interface_name,
             )
    
  • nautobot/dcim/tests/test_api.py+3 0 modified
    @@ -2092,7 +2092,10 @@ def setUp(self):
         def test_get_connected_device(self):
             url = reverse("dcim-api:connected-device-list")
             response = self.client.get(url + "?peer_device=TestDevice2&peer_interface=eth0", **self.header)
    +        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
     
    +        self.add_permissions("dcim.view_interface")
    +        response = self.client.get(url + "?peer_device=TestDevice2&peer_interface=eth0", **self.header)
             self.assertHttpStatus(response, status.HTTP_200_OK)
             self.assertEqual(response.data["name"], self.device1.name)
     
    
  • nautobot/dcim/views.py+5 2 modified
    @@ -11,7 +11,7 @@
         ModelMultipleChoiceField,
         MultipleHiddenInput,
     )
    -from django.shortcuts import get_object_or_404, redirect, render
    +from django.shortcuts import get_object_or_404, HttpResponse, redirect, render
     from django.utils.functional import cached_property
     from django.utils.html import format_html
     from django.views.generic import View
    @@ -2316,7 +2316,7 @@ def dispatch(self, request, *args, **kwargs):
                 "rear-port": forms.ConnectCableToRearPortForm,
                 "power-feed": forms.ConnectCableToPowerFeedForm,
                 "circuit-termination": forms.ConnectCableToCircuitTerminationForm,
    -        }[kwargs.get("termination_b_type")]
    +        }.get(kwargs.get("termination_b_type"), None)
     
             return super().dispatch(request, *args, **kwargs)
     
    @@ -2333,6 +2333,9 @@ def alter_obj(self, obj, request, url_args, url_kwargs):
             return obj
     
         def get(self, request, *args, **kwargs):
    +        if self.model_form is None:
    +            return HttpResponse(status_code=400)
    +
             obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
     
             # Parse initial data manually to avoid setting field values as lists
    
  • nautobot/docs/development/apps/api/views/core-view-overrides.md+6 1 modified
    @@ -13,8 +13,13 @@ A simple example to override the device detail view:
     from django.shortcuts import HttpResponse
     from django.views import generic
     
    +from nautobot.apps.views import ObjectPermissionRequiredMixin
    +
    +
    +class DeviceViewOverride(ObjectPermissionRequiredMixin, generic.View):
    +    def get_required_permission(self):
    +        return "dcim.view_device"
     
    -class DeviceViewOverride(generic.View):
         def get(self, request, *args, **kwargs):
             return HttpResponse(("Hello world! I'm a view which "
                                  "overrides the device object detail view."))
    
  • nautobot/docs/development/apps/api/views/django-generic-views.md+6 2 modified
    @@ -5,12 +5,13 @@ The use of `generic` Django views can aid in app development. As an example, let
     ```python
     # views.py
     from django.shortcuts import render
    -from django.views.generic import View
    +
    +from nautobot.apps.views import GenericView
     
     from .models import Animal
     
     
    -class RandomAnimalView(View):
    +class RandomAnimalView(GenericView):
         """Display a randomly-selected Animal."""
     
         def get(self, request):
    @@ -20,4 +21,7 @@ class RandomAnimalView(View):
             })
     ```
     
    +!!! tip
    +    The `nautobot.apps.views.GenericView` class was added in Nautobot 1.6.16 and 2.1.9. If you're developing against an earlier version, you can use `django.views.generic.View` in combination with the `django.contrib.auth.mixins.LoginRequiredMixin` instead.
    +
     This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/nautobot_animal_sounds/` within the app source directory. (We use the app's name as a subdirectory to guard against naming collisions with other apps.) Then, create a template named `animal.html` as described below.
    
  • nautobot/extras/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     
     from . import views
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.ExtrasRootView
    +router = OrderedDefaultRouter(view_name="Extras")
     
     # Computed Fields
     router.register("computed-fields", views.ComputedFieldViewSet)
    
  • nautobot/extras/api/views.py+0 10 modified
    @@ -16,7 +16,6 @@
     from rest_framework.parsers import JSONParser, MultiPartParser
     from rest_framework.permissions import IsAuthenticated
     from rest_framework.response import Response
    -from rest_framework.routers import APIRootView
     
     from nautobot.core.api.authentication import TokenPermissions
     from nautobot.core.api.utils import get_serializer_for_model
    @@ -74,15 +73,6 @@
     from . import serializers
     
     
    -class ExtrasRootView(APIRootView):
    -    """
    -    Extras API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Extras"
    -
    -
     class NotesViewSetMixin:
         def restrict_queryset(self, request, *args, **kwargs):
             """
    
  • nautobot/extras/plugins/views.py+6 9 modified
    @@ -2,7 +2,6 @@
     
     from django.apps import apps
     from django.conf import settings
    -from django.contrib.auth.mixins import LoginRequiredMixin
     from django.http import Http404
     from django.shortcuts import render
     from django.urls.exceptions import NoReverseMatch
    @@ -14,8 +13,9 @@
     from rest_framework.reverse import reverse
     from rest_framework.views import APIView
     
    -from nautobot.core.api.views import NautobotAPIVersionMixin
    +from nautobot.core.api.views import AuthenticatedAPIRootView, NautobotAPIVersionMixin
     from nautobot.core.forms import TableConfigForm
    +from nautobot.core.views.generic import GenericView
     from nautobot.core.views.mixins import AdminRequiredMixin
     from nautobot.core.views.paginator import EnhancedPaginator, get_paginate_count
     from nautobot.extras.plugins.tables import InstalledPluginsTable
    @@ -67,7 +67,7 @@ def get(self, request):
             )
     
     
    -class InstalledPluginDetailView(LoginRequiredMixin, View):
    +class InstalledPluginDetailView(GenericView):
         """
         View for showing details of an installed plugin.
         """
    @@ -92,7 +92,6 @@ class InstalledPluginsAPIView(NautobotAPIVersionMixin, APIView):
         """
     
         permission_classes = [permissions.IsAdminUser]
    -    _ignore_model_permissions = True
     
         def get_view_name(self):
             return "Installed Plugins"
    @@ -128,11 +127,9 @@ def get(self, request, format=None):  # pylint: disable=redefined-builtin
             return Response([self._get_plugin_data(apps.get_app_config(plugin)) for plugin in settings.PLUGINS])
     
     
    -class PluginsAPIRootView(NautobotAPIVersionMixin, APIView):
    -    _ignore_model_permissions = True
    -
    -    def get_view_name(self):
    -        return "Plugins"
    +class PluginsAPIRootView(AuthenticatedAPIRootView):
    +    name = "Apps"
    +    description = "API extension point for installed Nautobot Apps"
     
         @staticmethod
         def _get_plugin_entry(plugin, app_config, request, format_):
    
  • nautobot/extras/tests/test_views.py+101 0 modified
    @@ -21,6 +21,7 @@
     from nautobot.extras.choices import (
         CustomFieldTypeChoices,
         JobExecutionType,
    +    LogLevelChoices,
         ObjectChangeActionChoices,
         SecretsGroupAccessTypeChoices,
         SecretsGroupSecretTypeChoices,
    @@ -40,6 +41,7 @@
         GraphQLQuery,
         Job,
         JobButton,
    +    JobLogEntry,
         JobResult,
         Note,
         ObjectChange,
    @@ -617,6 +619,49 @@ def setUpTestData(cls):
                 "dynamic_group_memberships-MAX_NUM_FORMS": "1000",
             }
     
    +    def test_get_object_dynamic_groups_anonymous(self):
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
    +        self.client.logout()
    +        response = self.client.get(url, follow=True)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +
    +    def test_get_object_dynamic_groups_without_permission(self):
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, [403, 404])
    +
    +    def test_get_object_dynamic_groups_with_permission(self):
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
    +        self.add_permissions("dcim.view_device", "extras.view_dynamicgroup")
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, 200)
    +        response_body = response.content.decode(response.charset)
    +        self.assertIn("DG 1", response_body, msg=response_body)
    +        self.assertIn("DG 2", response_body, msg=response_body)
    +        self.assertIn("DG 3", response_body, msg=response_body)
    +
    +    def test_get_object_dynamic_groups_with_constrained_permission(self):
    +        self.add_permissions("extras.view_dynamicgroup")
    +        obj_perm = ObjectPermission(
    +            name="View a device",
    +            constraints={"pk": Device.objects.first().pk},
    +            actions=["view"],
    +        )
    +        obj_perm.save()
    +        obj_perm.users.add(self.user)
    +        obj_perm.object_types.add(ContentType.objects.get_for_model(Device))
    +
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.first().pk})
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, 200)
    +        response_body = response.content.decode(response.charset)
    +        self.assertIn("DG 1", response_body, msg=response_body)
    +
    +        url = reverse("dcim:device_dynamicgroups", kwargs={"pk": Device.objects.last().pk})
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, 404)
    +
         @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
         def test_edit_saved_filter(self):
             """Test that editing a filter works using the edit view."""
    @@ -786,6 +831,34 @@ def test_edit_object_with_constrained_permission(self):
             self.form_data = form_data
             super().test_edit_object_with_constrained_permission()
     
    +    def test_post_sync_repo_anonymous(self):
    +        self.client.logout()
    +        url = reverse("extras:gitrepository_sync", kwargs={"pk": self._get_queryset().first().pk})
    +        response = self.client.post(url, follow=True)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +
    +    def test_post_sync_repo_without_permission(self):
    +        url = reverse("extras:gitrepository_sync", kwargs={"pk": self._get_queryset().first().pk})
    +        response = self.client.post(url)
    +        self.assertHttpStatus(response, [403, 404])
    +
    +    # TODO: mock/stub out `enqueue_pull_git_repository_and_refresh_data` and test successful POST with permissions
    +
    +    def test_post_dryrun_repo_anonymous(self):
    +        self.client.logout()
    +        url = reverse("extras:gitrepository_dryrun", kwargs={"pk": self._get_queryset().first().pk})
    +        response = self.client.post(url, follow=True)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +
    +    def test_post_dryrun_repo_without_permission(self):
    +        url = reverse("extras:gitrepository_dryrun", kwargs={"pk": self._get_queryset().first().pk})
    +        response = self.client.post(url)
    +        self.assertHttpStatus(response, [403, 404])
    +
    +    # TODO: mock/stub out `enqueue_git_repository_diff_origin_and_local` and test successful POST with permissions
    +
     
     class NoteTestCase(
         ViewTestCases.CreateObjectViewTestCase,
    @@ -1503,6 +1576,34 @@ class JobResultTestCase(
         def setUpTestData(cls):
             JobResult.objects.create(name="pass.TestPass")
             JobResult.objects.create(name="fail.TestFail")
    +        JobLogEntry.objects.create(
    +            log_level=LogLevelChoices.LOG_INFO,
    +            job_result=JobResult.objects.first(),
    +            grouping="run",
    +            message="This is a test",
    +        )
    +
    +    def test_get_joblogentrytable_anonymous(self):
    +        url = reverse("extras:jobresult_log-table", kwargs={"pk": JobResult.objects.first().pk})
    +        self.client.logout()
    +        response = self.client.get(url, follow=True)
    +        self.assertHttpStatus(response, 200)
    +        self.assertRedirects(response, f"/login/?next={url}")
    +
    +    def test_get_joblogentrytable_without_permission(self):
    +        url = reverse("extras:jobresult_log-table", kwargs={"pk": JobResult.objects.first().pk})
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, [403, 404])
    +
    +    def test_get_joblogentrytable_with_permission(self):
    +        url = reverse("extras:jobresult_log-table", kwargs={"pk": JobResult.objects.first().pk})
    +        self.add_permissions("extras.view_jobresult", "extras.view_joblogentry")
    +        response = self.client.get(url)
    +        self.assertHttpStatus(response, 200)
    +        response_body = response.content.decode(response.charset)
    +        self.assertIn("This is a test", response_body)
    +
    +    # TODO test with constrained permissions on both JobResult and JobLogEntry records
     
     
     class JobTestCase(
    
  • nautobot/extras/views.py+10 10 modified
    @@ -713,7 +713,7 @@ class DynamicGroupBulkDeleteView(generic.BulkDeleteView):
         filterset = filters.DynamicGroupFilterSet
     
     
    -class ObjectDynamicGroupsView(View):
    +class ObjectDynamicGroupsView(generic.GenericView):
         """
         Present a list of dynamic groups associated to a particular object.
         base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
    @@ -912,18 +912,18 @@ def check_and_call_git_repository_function(request, pk, func):
             messages.error(request, "Unable to run job: Celery worker process not running.")
             return redirect(request.get_full_path(), permanent=False)
         else:
    -        repository = get_object_or_404(GitRepository, pk=pk)
    +        repository = get_object_or_404(GitRepository.objects.restrict(request.user, "change"), pk=pk)
             job_result = func(repository, request.user)
     
         return redirect(job_result.get_absolute_url())
     
     
    -class GitRepositorySyncView(View):
    +class GitRepositorySyncView(generic.GenericView):
         def post(self, request, pk):
             return check_and_call_git_repository_function(request, pk, enqueue_pull_git_repository_and_refresh_data)
     
     
    -class GitRepositoryDryRunView(View):
    +class GitRepositoryDryRunView(generic.GenericView):
         def post(self, request, pk):
             return check_and_call_git_repository_function(request, pk, enqueue_git_repository_diff_origin_and_local)
     
    @@ -1559,15 +1559,15 @@ def get_extra_context(self, request, instance):
             }
     
     
    -class JobLogEntryTableView(View):
    +class JobLogEntryTableView(generic.GenericView):
         """
         Display a table of `JobLogEntry` objects for a given `JobResult` instance.
         """
     
         queryset = JobResult.objects.all()
     
         def get(self, request, pk=None):
    -        instance = self.queryset.get(pk=pk)
    +        instance = get_object_or_404(self.queryset.restrict(request.user, "view"), pk=pk)
             filter_q = request.GET.get("q")
             if filter_q:
                 queryset = instance.job_log_entries.filter(
    @@ -1646,7 +1646,7 @@ def get_extra_context(self, request, instance):
             }
     
     
    -class ObjectChangeLogView(View):
    +class ObjectChangeLogView(generic.GenericView):
         """
         Present a history of changes made to a particular object.
         base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
    @@ -1730,7 +1730,7 @@ class NoteDeleteView(generic.ObjectDeleteView):
         queryset = Note.objects.all()
     
     
    -class ObjectNotesView(View):
    +class ObjectNotesView(generic.GenericView):
         """
         Present a list of notes associated to a particular object.
         base_template: The name of the template to extend. If not provided, "<app>/<model>.html" will be used.
    @@ -1751,7 +1751,7 @@ def get(self, request, model, **kwargs):
                     "assigned_object_id": obj.pk,
                 }
             )
    -        notes_table = tables.NoteTable(obj.notes)
    +        notes_table = tables.NoteTable(obj.notes.restrict(request.user, "view"))
     
             # Apply the request context
             paginate = {
    @@ -1972,7 +1972,7 @@ def get_extra_context(self, request, instance):
             }
     
     
    -class SecretProviderParametersFormView(View):
    +class SecretProviderParametersFormView(generic.GenericView):
         """
         Helper view to SecretView; retrieve the HTML form appropriate for entering parameters for a given SecretsProvider.
         """
    
  • nautobot/ipam/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     
     from . import views
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.IPAMRootView
    +router = OrderedDefaultRouter(view_name="IPAM")
     
     # Namespaces
     router.register("namespaces", views.NamespaceViewSet)
    
  • nautobot/ipam/api/views.py+0 11 modified
    @@ -5,7 +5,6 @@
     from rest_framework import status
     from rest_framework.decorators import action
     from rest_framework.response import Response
    -from rest_framework.routers import APIRootView
     
     from nautobot.core.models.querysets import count_related
     from nautobot.core.utils.config import get_settings_or_config
    @@ -26,16 +25,6 @@
     
     from . import serializers
     
    -
    -class IPAMRootView(APIRootView):
    -    """
    -    IPAM API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "IPAM"
    -
    -
     #
     # Namespace
     #
    
  • nautobot/ipam/tests/test_graphql.py+2 3 modified
    @@ -1,4 +1,3 @@
    -from django.test import override_settings
     from django.urls import reverse
     from rest_framework import status
     
    @@ -9,8 +8,8 @@ class TestPrefix(APITestCase):
         def setUp(self):
             super().setUp()
             self.api_url = reverse("graphql-api")
    +        self.add_permissions("ipam.view_prefix")
     
    -    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
         def test_prefix_ip_version(self):
             """Test ip_version is available for a Prefix via GraphQL."""
             get_prefixes_query = """
    @@ -23,7 +22,7 @@ def test_prefix_ip_version(self):
             }
             """
             payload = {"query": get_prefixes_query}
    -        response = self.client.post(self.api_url, payload, format="json")
    +        response = self.client.post(self.api_url, payload, format="json", **self.header)
             self.assertEqual(response.status_code, status.HTTP_200_OK)
             prefixes = response.data["data"]["prefixes"]
             self.assertIsInstance(prefixes, list)
    
  • nautobot/ipam/views.py+9 9 modified
    @@ -775,7 +775,7 @@ def dispatch(self, request, *args, **kwargs):
                 _, error_msg = retrieve_interface_or_vminterface_from_request(request)
                 if error_msg:
                     messages.warning(request, error_msg)
    -                return redirect(self.get_return_url(request), default_return_url="ipam:ipaddress_add")
    +                return redirect(self.get_return_url(request, default_return_url="ipam:ipaddress_add"))
     
             return super().dispatch(request, *args, **kwargs)
     
    @@ -858,17 +858,17 @@ class IPAddressAssignView(view_mixins.GetReturnURLMixin, generic.ObjectView):
         """
     
         queryset = IPAddress.objects.all()
    -    default_return_url = "ipam:ipaddress_add"
     
         def dispatch(self, request, *args, **kwargs):
    -        # Redirect user if an interface has not been provided
    -        if "interface" not in request.GET and "vminterface" not in request.GET:
    -            return redirect(self.get_return_url(request))
    +        if request.user.is_authenticated:
    +            # Redirect user if an interface has not been provided
    +            if "interface" not in request.GET and "vminterface" not in request.GET:
    +                return redirect(self.get_return_url(request, default_return_url="ipam:ipaddress_add"))
     
    -        _, error_msg = retrieve_interface_or_vminterface_from_request(request)
    -        if error_msg:
    -            messages.warning(request, error_msg)
    -            return redirect(self.get_return_url(request))
    +            _, error_msg = retrieve_interface_or_vminterface_from_request(request)
    +            if error_msg:
    +                messages.warning(request, error_msg)
    +                return redirect(self.get_return_url(request, default_return_url="ipam:ipaddress_add"))
     
             return super().dispatch(request, *args, **kwargs)
     
    
  • nautobot/tenancy/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     
     from . import views
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.TenancyRootView
    +router = OrderedDefaultRouter(view_name="Tenancy")
     
     # Tenants
     router.register("tenant-groups", views.TenantGroupViewSet)
    
  • nautobot/tenancy/api/views.py+0 12 modified
    @@ -1,5 +1,3 @@
    -from rest_framework.routers import APIRootView
    -
     from nautobot.circuits.models import Circuit
     from nautobot.core.models.querysets import count_related
     from nautobot.dcim.models import Device, Rack
    @@ -11,16 +9,6 @@
     
     from . import serializers
     
    -
    -class TenancyRootView(APIRootView):
    -    """
    -    Tenancy API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Tenancy"
    -
    -
     #
     # Tenant Groups
     #
    
  • nautobot/users/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     
     from . import views
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.UsersRootView
    +router = OrderedDefaultRouter(view_name="Users")
     
     # Users and groups
     router.register("users", views.UserViewSet)
    
  • nautobot/users/api/views.py+2 65 modified
    @@ -1,14 +1,10 @@
    -from django.contrib.auth import get_user_model, login, logout
    +from django.contrib.auth import get_user_model
     from django.contrib.auth.models import Group
     from django.db.models import Count
    -from django.utils.decorators import method_decorator
    -from django.views.decorators.csrf import ensure_csrf_cookie
     from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiTypes
     from rest_framework.authentication import BasicAuthentication
    -from rest_framework.decorators import action
    -from rest_framework.permissions import AllowAny, IsAuthenticated
    +from rest_framework.permissions import IsAuthenticated
     from rest_framework.response import Response
    -from rest_framework.routers import APIRootView
     from rest_framework.viewsets import ViewSet
     
     from nautobot.core.api.serializers import BulkOperationIntegerIDSerializer
    @@ -20,16 +16,6 @@
     
     from . import serializers
     
    -
    -class UsersRootView(APIRootView):
    -    """
    -    Users API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Users"
    -
    -
     #
     # Users and groups
     #
    @@ -40,40 +26,6 @@ class UserViewSet(ModelViewSet):
         serializer_class = serializers.UserSerializer
         filterset_class = filters.UserFilterSet
     
    -    @action(methods=["GET"], detail=False, url_path="my-profile")
    -    def my_profile(self, request):
    -        serializer = self.serializer_class(instance=request.user, context={"request": request})
    -        return Response(serializer.data)
    -
    -    @method_decorator(ensure_csrf_cookie)
    -    @action(methods=["GET"], detail=False, permission_classes=[AllowAny])
    -    def session(self, request):
    -        from django.conf import settings as django_settings
    -        from django.urls import reverse
    -        from social_django.context_processors import backends
    -
    -        from nautobot.core.settings_funcs import sso_auth_enabled
    -
    -        serializer = self.serializer_class(instance=request.user, context={"request": request})
    -
    -        _backends = []
    -        sso_enabled = sso_auth_enabled(django_settings.AUTHENTICATION_BACKENDS)
    -
    -        social_auth_backends = backends(request)["backends"]
    -        if sso_enabled:
    -            for backend in social_auth_backends["backends"]:
    -                _backends.append(reverse("social:begin", kwargs={"backend": backend}))
    -
    -        resp = {
    -            "user": serializer.data,
    -            "logged_in": request.user.is_authenticated,
    -            "sso_enabled": sso_enabled,
    -            "sso_user": (len(social_auth_backends["associated"]) > 0),
    -            "backends": _backends,
    -        }
    -
    -        return Response(resp)
    -
     
     @extend_schema_view(
         bulk_destroy=extend_schema(request=BulkOperationIntegerIDSerializer(many=True)),
    @@ -101,21 +53,6 @@ def authentication_classes(self):
             classes = super().authentication_classes
             return [*classes, BasicAuthentication]
     
    -    # TODO(timizuo): Move authenticate and logout to its own view;
    -    #  as it is not proper to be on this.
    -    @action(methods=["POST"], detail=False, permission_classes=[AllowAny])
    -    def authenticate(self, request):
    -        serializer = serializers.UserLoginSerializer(data=request.data, context=self.get_serializer_context())
    -        serializer.is_valid(raise_exception=True)
    -        user = serializer.validated_data["user"]
    -        login(request, user=user)
    -        return Response(status=200)
    -
    -    @action(methods=["GET"], detail=False)
    -    def logout(self, request):
    -        logout(request)
    -        return Response(status=200)
    -
         def get_queryset(self):
             """
             Limit users to their own Tokens.
    
  • nautobot/users/views.py+8 8 modified
    @@ -7,7 +7,6 @@
         logout as auth_logout,
         update_session_auth_hash,
     )
    -from django.contrib.auth.mixins import LoginRequiredMixin
     from django.http import HttpResponseForbidden, HttpResponseRedirect
     from django.shortcuts import get_object_or_404, redirect, render
     from django.urls import reverse
    @@ -18,6 +17,7 @@
     from django.views.generic import View
     
     from nautobot.core.forms import ConfirmationForm
    +from nautobot.core.views.generic import GenericView
     
     from .forms import AdvancedProfileSettingsForm, LoginForm, PasswordChangeForm, TokenForm
     from .models import Token
    @@ -118,7 +118,7 @@ def is_django_auth_user(request):
         return request.session.get(BACKEND_SESSION_KEY, None) == "nautobot.core.authentication.ObjectPermissionBackend"
     
     
    -class ProfileView(LoginRequiredMixin, View):
    +class ProfileView(GenericView):
         template_name = "users/profile.html"
     
         def get(self, request):
    @@ -132,7 +132,7 @@ def get(self, request):
             )
     
     
    -class UserConfigView(LoginRequiredMixin, View):
    +class UserConfigView(GenericView):
         template_name = "users/preferences.html"
     
         def get(self, request):
    @@ -160,7 +160,7 @@ def post(self, request):
             return redirect("user:preferences")
     
     
    -class ChangePasswordView(LoginRequiredMixin, View):
    +class ChangePasswordView(GenericView):
         template_name = "users/change_password.html"
     
         RESTRICTED_NOTICE = "Remotely authenticated user credentials cannot be changed within Nautobot."
    @@ -218,7 +218,7 @@ def post(self, request):
     #
     
     
    -class TokenListView(LoginRequiredMixin, View):
    +class TokenListView(GenericView):
         def get(self, request):
             tokens = Token.objects.filter(user=request.user)
     
    @@ -233,7 +233,7 @@ def get(self, request):
             )
     
     
    -class TokenEditView(LoginRequiredMixin, View):
    +class TokenEditView(GenericView):
         def get(self, request, pk=None):
             if pk is not None:
                 if not request.user.has_perm("users.change_token"):
    @@ -292,7 +292,7 @@ def post(self, request, pk=None):
             )
     
     
    -class TokenDeleteView(LoginRequiredMixin, View):
    +class TokenDeleteView(GenericView):
         def get(self, request, pk):
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
             initial_data = {
    @@ -336,7 +336,7 @@ def post(self, request, pk):
     #
     
     
    -class AdvancedProfileSettingsEditView(LoginRequiredMixin, View):
    +class AdvancedProfileSettingsEditView(GenericView):
         template_name = "users/advanced_settings_edit.html"
     
         def get(self, request):
    
  • nautobot/virtualization/api/urls.py+1 2 modified
    @@ -2,8 +2,7 @@
     
     from . import views
     
    -router = OrderedDefaultRouter()
    -router.APIRootView = views.VirtualizationRootView
    +router = OrderedDefaultRouter(view_name="Virtualization")
     
     # Clusters
     router.register("cluster-types", views.ClusterTypeViewSet)
    
  • nautobot/virtualization/api/views.py+0 12 modified
    @@ -1,5 +1,3 @@
    -from rest_framework.routers import APIRootView
    -
     from nautobot.core.models.querysets import count_related
     from nautobot.dcim.models import Device
     from nautobot.extras.api.views import (
    @@ -19,16 +17,6 @@
     
     from . import serializers
     
    -
    -class VirtualizationRootView(APIRootView):
    -    """
    -    Virtualization API root view
    -    """
    -
    -    def get_view_name(self):
    -        return "Virtualization"
    -
    -
     #
     # Clusters
     #
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

9

News mentions

0

No linked articles in our index yet.