CVE-2024-29199 (published 2024-03-26T03:15Z)

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.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/nautobot/nautobot
commit_details: repo_url: https://github.com/nautobot/nautobot
author: Glenn Matthews
author_date: 2024-03-25 10:36:49-04:00
msg: View authentication and permission fixes (#5464)
repository: repo_url: https://github.com/nautobot/nautobot
repo_name: nautobot/nautobot
description: Network Source of Truth & Network Automation Platform
repo_language: Python
owner: nautobot
file_changes: filename: 5464.added
change_type: ModificationType.ADD
diff: @@ -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.

programming_language: Markdown
filename: 5464.changed
change_type: ModificationType.ADD
diff: @@ -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.

programming_language: Markdown
filename: 5464.documentation
change_type: ModificationType.ADD
diff: @@ -0,0 +1 @@
+Updated example views in the App developer documentation to include `ObjectPermissionRequiredMixin` or `LoginRequiredMixin` as appropriate best practices.

programming_language: Markdown
filename: 5464.fixed
change_type: ModificationType.ADD
diff: @@ -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.

programming_language: SQL
filename: 5464.housekeeping
change_type: ModificationType.ADD
diff: @@ -0,0 +1 @@
+Updated custom views in the `example_plugin` to use the new `GenericView` base class as a best practice.

programming_language: Markdown
filename: 5464.removed
change_type: ModificationType.ADD
diff: @@ -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.

programming_language: JavaScript
filename: 5464.security
change_type: ModificationType.ADD
diff: @@ -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))

programming_language: Markdown
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -4,7 +4,7 @@ from nautobot.apps.api import OrderedDefaultRouter
 
 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)
 

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -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.")

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -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.")
 

programming_language: Python
filename: api.py
change_type: ModificationType.MODIFY
diff: @@ -19,7 +19,7 @@ from nautobot.core.api.fields import (
 )
 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 @@ from nautobot.core.api.utils import (
     versioned_serializer_selector,
 )
 from nautobot.core.api.views import (
-    APIRootView,
     BulkDestroyModelMixin,
     BulkUpdateModelMixin,
     GetObjectCountsView,

programming_language: Python
filename: utils.py
change_type: ModificationType.MODIFY
diff: @@ -32,6 +32,8 @@ from nautobot.core.utils.lookup import (
     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 @@ __all__ = (
     "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",

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -8,6 +8,7 @@ from nautobot.core.views.generic import (
     BulkImportView,
     BulkRenameView,
     ComponentCreateView,
+    GenericView,
     ObjectDeleteView,
     ObjectEditView,
     ObjectImportView,
@@ -57,6 +58,7 @@ __all__ = (
     "csv_format",
     "EnhancedPage",
     "EnhancedPaginator",
+    "GenericView",
     "get_csv_form_fields_from_serializer_class",
     "get_paginate_count",
     "GetReturnURLMixin",

programming_language: CoffeeScript
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -2,8 +2,7 @@ from nautobot.core.api.routers import OrderedDefaultRouter
 
 from . import views
 
-router = OrderedDefaultRouter()
-router.APIRootView = views.CircuitsRootView
+router = OrderedDefaultRouter(view_name="Circuits")
 
 # Providers
 router.register("providers", views.ProviderViewSet)

programming_language: CoffeeScript
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -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 nautobot.extras.api.views import NautobotModelViewSet
 
 from . import serializers
 
-
-class CircuitsRootView(APIRootView):
-    """
-    Circuits API root view
-    """
-
-    def get_view_name(self):
-        return "Circuits"
-
-
 #
 # Providers
 #

programming_language: Python
filename: routers.py
change_type: ModificationType.MODIFY
diff: @@ -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 @@ class OrderedDefaultRouter(DefaultRouter):
         """
         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)

programming_language: Python
filename: utils.py
change_type: ModificationType.MODIFY
diff: @@ -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")

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -22,9 +22,9 @@ from graphql import get_default_backend
 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 NautobotSpectacularSwaggerView(APIVersioningGetSchemaURLMixin, Spectacular
     @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

programming_language: Python
filename: settings.py
change_type: ModificationType.MODIFY
diff: @@ -242,6 +242,7 @@ SPECTACULAR_SETTINGS = {
     # 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

programming_language: Python
filename: test_view_authentication.py
change_type: ModificationType.ADD
diff: @@ -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)
+                        )

programming_language: Python
filename: test_graphql.py
change_type: ModificationType.MODIFY
diff: @@ -630,21 +630,9 @@ class GraphQLAPIPermissionTest(GraphQLTestCaseBase):
         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."""

programming_language: Python
filename: test_views.py
change_type: ModificationType.MODIFY
diff: @@ -315,25 +315,31 @@ class LoginUI(TestCase):
         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):

programming_language: Python
filename: lookup.py
change_type: ModificationType.MODIFY
diff: @@ -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

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,7 @@ from django.contrib.auth.decorators import permission_required
 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 @@ class SearchView(AccessMixin, View):
         )
 
 
-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)

programming_language: Python
filename: generic.py
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,7 @@ import re
 
 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.models import ExportTemplate
 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.

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -2,8 +2,7 @@ from nautobot.core.api.routers import OrderedDefaultRouter
 
 from . import views
 
-router = OrderedDefaultRouter()
-router.APIRootView = views.DCIMRootView
+router = OrderedDefaultRouter(view_name="DCIM")
 
 # Locations
 router.register("location-types", views.LocationTypeViewSet)

programming_language: CoffeeScript
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -13,7 +13,6 @@ from rest_framework.decorators import action
 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 nautobot.virtualization.models import VirtualMachine
 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 @@ class ConnectedDeviceViewSet(ViewSet):
 
         # 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,
         )

programming_language: Python
filename: test_api.py
change_type: ModificationType.MODIFY
diff: @@ -2092,7 +2092,10 @@ class ConnectedDeviceTest(APITestCase):
     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)
 

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -11,7 +11,7 @@ from django.forms import (
     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 @@ class CableCreateView(generic.ObjectEditView):
             "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 @@ class CableCreateView(generic.ObjectEditView):
         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

programming_language: Python
filename: core-view-overrides.md
change_type: ModificationType.MODIFY
diff: @@ -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."))

programming_language: Python
filename: django-generic-views.md
change_type: ModificationType.MODIFY
diff: @@ -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.

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -2,8 +2,7 @@ from nautobot.core.api.routers import OrderedDefaultRouter
 
 from . import views
 
-router = OrderedDefaultRouter()
-router.APIRootView = views.ExtrasRootView
+router = OrderedDefaultRouter(view_name="Extras")
 
 # Computed Fields
 router.register("computed-fields", views.ComputedFieldViewSet)

programming_language: CoffeeScript
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -16,7 +16,6 @@ from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, Valida
 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 nautobot.extras.utils import get_worker_count
 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):
         """

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -2,7 +2,6 @@ from collections import OrderedDict
 
 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.response import Response
 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 @@ class InstalledPluginsView(AdminRequiredMixin, View):
         )
 
 
-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 @@ class InstalledPluginsAPIView(NautobotAPIVersionMixin, APIView):
         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_):

programming_language: Python
filename: test_views.py
change_type: ModificationType.MODIFY
diff: @@ -21,6 +21,7 @@ from nautobot.dcim.tests import test_views
 from nautobot.extras.choices import (
     CustomFieldTypeChoices,
     JobExecutionType,
+    LogLevelChoices,
     ObjectChangeActionChoices,
     SecretsGroupAccessTypeChoices,
     SecretsGroupSecretTypeChoices,
@@ -40,6 +41,7 @@ from nautobot.extras.models import (
     GraphQLQuery,
     Job,
     JobButton,
+    JobLogEntry,
     JobResult,
     Note,
     ObjectChange,
@@ -617,6 +619,49 @@ class DynamicGroupTestCase(
             "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 @@ class GitRepositoryTestCase(
         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(

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -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,7 +1559,7 @@ class JobResultView(generic.ObjectView):
         }
 
 
-class JobLogEntryTableView(View):
+class JobLogEntryTableView(generic.GenericView):
     """
     Display a table of `JobLogEntry` objects for a given `JobResult` instance.
     """
@@ -1567,7 +1567,7 @@ class JobLogEntryTableView(View):
     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 @@ class ObjectChangeView(generic.ObjectView):
         }
 
 
-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 @@ class ObjectNotesView(View):
                 "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 @@ class SecretView(generic.ObjectView):
         }
 
 
-class SecretProviderParametersFormView(View):
+class SecretProviderParametersFormView(generic.GenericView):
     """
     Helper view to SecretView; retrieve the HTML form appropriate for entering parameters for a given SecretsProvider.
     """

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -2,8 +2,7 @@ from nautobot.core.api.routers import OrderedDefaultRouter
 
 from . import views
 
-router = OrderedDefaultRouter()
-router.APIRootView = views.IPAMRootView
+router = OrderedDefaultRouter(view_name="IPAM")
 
 # Namespaces
 router.register("namespaces", views.NamespaceViewSet)

programming_language: CoffeeScript
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -5,7 +5,6 @@ from drf_spectacular.utils import extend_schema
 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 nautobot.ipam.models import (
 
 from . import serializers
 
-
-class IPAMRootView(APIRootView):
-    """
-    IPAM API root view
-    """
-
-    def get_view_name(self):
-        return "IPAM"
-
-
 #
 # Namespace
 #

programming_language: Python
filename: test_graphql.py
change_type: ModificationType.MODIFY
diff: @@ -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 @@ class TestPrefix(APITestCase):
         }
         """
         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)

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -775,7 +775,7 @@ class IPAddressEditView(generic.ObjectEditView):
             _, 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)
 

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -2,8 +2,7 @@ from nautobot.core.api.routers import OrderedDefaultRouter
 
 from . import views
 
-router = OrderedDefaultRouter()
-router.APIRootView = views.TenancyRootView
+router = OrderedDefaultRouter(view_name="Tenancy")
 
 # Tenants
 router.register("tenant-groups", views.TenantGroupViewSet)

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -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 nautobot.virtualization.models import VirtualMachine
 
 from . import serializers
 
-
-class TenancyRootView(APIRootView):
-    """
-    Tenancy API root view
-    """
-
-    def get_view_name(self):
-        return "Tenancy"
-
-
 #
 # Tenant Groups
 #

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -2,8 +2,7 @@ from nautobot.core.api.routers import OrderedDefaultRouter
 
 from . import views
 
-router = OrderedDefaultRouter()
-router.APIRootView = views.UsersRootView
+router = OrderedDefaultRouter(view_name="Users")
 
 # Users and groups
 router.register("users", views.UserViewSet)

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -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 nautobot.users.models import ObjectPermission, Token
 
 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 @@ class TokenViewSet(ModelViewSet):
         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.

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -7,7 +7,6 @@ from django.contrib.auth import (
     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.decorators.debug import sensitive_post_parameters
 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 @@ class ProfileView(LoginRequiredMixin, View):
         )
 
 
-class UserConfigView(LoginRequiredMixin, View):
+class UserConfigView(GenericView):
     template_name = "users/preferences.html"
 
     def get(self, request):
@@ -160,7 +160,7 @@ class UserConfigView(LoginRequiredMixin, View):
         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 @@ class ChangePasswordView(LoginRequiredMixin, View):
 #
 
 
-class TokenListView(LoginRequiredMixin, View):
+class TokenListView(GenericView):
     def get(self, request):
         tokens = Token.objects.filter(user=request.user)
 
@@ -233,7 +233,7 @@ class TokenListView(LoginRequiredMixin, View):
         )
 
 
-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 @@ class TokenEditView(LoginRequiredMixin, View):
         )
 
 
-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 @@ class TokenDeleteView(LoginRequiredMixin, View):
 #
 
 
-class AdvancedProfileSettingsEditView(LoginRequiredMixin, View):
+class AdvancedProfileSettingsEditView(GenericView):
     template_name = "users/advanced_settings_edit.html"
 
     def get(self, request):

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -2,8 +2,7 @@ from nautobot.core.api.routers import OrderedDefaultRouter
 
 from . import views
 
-router = OrderedDefaultRouter()
-router.APIRootView = views.VirtualizationRootView
+router = OrderedDefaultRouter(view_name="Virtualization")
 
 # Clusters
 router.register("cluster-types", views.ClusterTypeViewSet)

programming_language: CoffeeScript
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -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 nautobot.virtualization.models import (
 
 from . import serializers
 
-
-class VirtualizationRootView(APIRootView):
-    """
-    Virtualization API root view
-    """
-
-    def get_view_name(self):
-        return "Virtualization"
-
-
 #
 # Clusters
 #

programming_language: Python