CVE-2024-29199: Insufficient Information (NVD-CWE-noinfo)
value: 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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-03-26T03:15Z
Repository: https://github.com/nautobot/nautobot
Language: Python
Files changed: changes/5464.added, changes/5464.changed, changes/5464.documentation, changes/5464.fixed, changes/5464.housekeeping, changes/5464.removed, changes/5464.security, examples/example_plugin/example_plugin/api/urls.py, examples/example_plugin/example_plugin/views.py, examples/example_plugin_with_view_override/example_plugin_with_view_override/views.py, nautobot/apps/api.py, nautobot/apps/utils.py, nautobot/apps/views.py, nautobot/circuits/api/urls.py, nautobot/circuits/api/views.py, nautobot/core/api/routers.py, nautobot/core/api/utils.py, nautobot/core/api/views.py, nautobot/core/settings.py, nautobot/core/tests/integration/test_view_authentication.py, nautobot/core/tests/test_graphql.py, nautobot/core/tests/test_views.py, nautobot/core/utils/lookup.py, nautobot/core/views/__init__.py, nautobot/core/views/generic.py, nautobot/dcim/api/urls.py, nautobot/dcim/api/views.py, nautobot/dcim/tests/test_api.py, nautobot/dcim/views.py, nautobot/docs/development/apps/api/views/core-view-overrides.md, nautobot/docs/development/apps/api/views/django-generic-views.md, nautobot/extras/api/urls.py, nautobot/extras/api/views.py, nautobot/extras/plugins/views.py, nautobot/extras/tests/test_views.py, nautobot/extras/views.py, nautobot/ipam/api/urls.py, nautobot/ipam/api/views.py, nautobot/ipam/tests/test_graphql.py, nautobot/ipam/views.py, nautobot/tenancy/api/urls.py, nautobot/tenancy/api/views.py, nautobot/users/api/urls.py, nautobot/users/api/views.py, nautobot/users/views.py, nautobot/virtualization/api/urls.py, nautobot/virtualization/api/views.py
Commit date: 2024-03-25 09:36:49 -0500
Commit message: View authentication and permission fixes (#5464)


Diff: diff --git a/changes/5464.added b/changes/5464.added
new file mode 100644
index 0000000000..52a0d2e9f0
--- /dev/null
+++ b/changes/5464.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.
diff --git a/changes/5464.changed b/changes/5464.changed
new file mode 100644
index 0000000000..9127580b9c
--- /dev/null
+++ b/changes/5464.changed
@@ -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.
diff --git a/changes/5464.documentation b/changes/5464.documentation
new file mode 100644
index 0000000000..5c603855d7
--- /dev/null
+++ b/changes/5464.documentation
@@ -0,0 +1 @@
+Updated example views in the App developer documentation to include `ObjectPermissionRequiredMixin` or `LoginRequiredMixin` as appropriate best practices.
diff --git a/changes/5464.fixed b/changes/5464.fixed
new file mode 100644
index 0000000000..894d647fb7
--- /dev/null
+++ b/changes/5464.fixed
@@ -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.
diff --git a/changes/5464.housekeeping b/changes/5464.housekeeping
new file mode 100644
index 0000000000..c50de84633
--- /dev/null
+++ b/changes/5464.housekeeping
@@ -0,0 +1 @@
+Updated custom views in the `example_plugin` to use the new `GenericView` base class as a best practice.
diff --git a/changes/5464.removed b/changes/5464.removed
new file mode 100644
index 0000000000..9abff45f20
--- /dev/null
+++ b/changes/5464.removed
@@ -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.
diff --git a/changes/5464.security b/changes/5464.security
new file mode 100644
index 0000000000..16e87c3d30
--- /dev/null
+++ b/changes/5464.security
@@ -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))
diff --git a/examples/example_plugin/example_plugin/api/urls.py b/examples/example_plugin/example_plugin/api/urls.py
index 5a894c1103..0883e41021 100644
--- a/examples/example_plugin/example_plugin/api/urls.py
+++ b/examples/example_plugin/example_plugin/api/urls.py
@@ -2,11 +2,11 @@ from django.urls import include, path
 
 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)
 
 urlpatterns = [
     path(
diff --git a/examples/example_plugin/example_plugin/views.py b/examples/example_plugin/example_plugin/views.py
index bb082946ee..eda1ebe892 100644
--- a/examples/example_plugin/example_plugin/views.py
+++ b/examples/example_plugin/example_plugin/views.py
@@ -1,7 +1,6 @@
 from django.shortcuts import HttpResponse, render
-from django.views.generic import View
 from rest_framework.decorators import action
 
 from nautobot.apps import views
 from nautobot.circuits.models import Circuit
 from nautobot.dcim.models import Device
@@ -44,16 +43,16 @@ class DeviceDetailPluginTabTwoView(views.ObjectView):
 
     queryset = Device.objects.all()
     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.
 
         Just an example - in reality you'd want to use real config data here as appropriate to your plugin, if any.
         """
@@ -112,8 +111,8 @@ class AnotherExampleModelUIViewSet(
     queryset = AnotherExampleModel.objects.all()
     serializer_class = serializers.AnotherExampleModelSerializer
     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.")
diff --git a/examples/example_plugin_with_view_override/example_plugin_with_view_override/views.py b/examples/example_plugin_with_view_override/example_plugin_with_view_override/views.py
index 36186b53e0..dd58ea3bac 100644
--- a/examples/example_plugin_with_view_override/example_plugin_with_view_override/views.py
+++ b/examples/example_plugin_with_view_override/example_plugin_with_view_override/views.py
@@ -1,12 +1,13 @@
 """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.")
 
 
 override_views = {
diff --git a/nautobot/apps/api.py b/nautobot/apps/api.py
index 8ff3d6575c..4fdfe9caca 100644
--- a/nautobot/apps/api.py
+++ b/nautobot/apps/api.py
@@ -17,11 +17,11 @@ from nautobot.core.api.fields import (
     SerializedPKRelatedField,
     TimeZoneSerializerField,
 )
 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,
     TreeModelSerializerMixin,
 )
@@ -34,11 +34,10 @@ from nautobot.core.api.utils import (
     is_api_request,
     rest_api_server_error,
     versioned_serializer_selector,
 )
 from nautobot.core.api.views import (
-    APIRootView,
     BulkDestroyModelMixin,
     BulkUpdateModelMixin,
     GetObjectCountsView,
     ModelViewSet,
     ModelViewSetMixin,
diff --git a/nautobot/apps/utils.py b/nautobot/apps/utils.py
index 23ce4bb694..242c19e24c 100644
--- a/nautobot/apps/utils.py
+++ b/nautobot/apps/utils.py
@@ -30,10 +30,12 @@ from nautobot.core.utils.lookup import (
     get_form_for_model,
     get_model_from_name,
     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,
     get_only_new_ui_ready_routes,
     is_route_new_ui_ready,
@@ -108,10 +110,12 @@ __all__ = (
     "get_permission_for_model",
     "get_related_class_for_model",
     "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",
     "image_upload",
     "is_route_new_ui_ready",
diff --git a/nautobot/apps/views.py b/nautobot/apps/views.py
index 1e2c02dcf7..5358e0cbf9 100644
--- a/nautobot/apps/views.py
+++ b/nautobot/apps/views.py
@@ -6,10 +6,11 @@ from nautobot.core.views.generic import (
     BulkDeleteView,
     BulkEditView,
     BulkImportView,
     BulkRenameView,
     ComponentCreateView,
+    GenericView,
     ObjectDeleteView,
     ObjectEditView,
     ObjectImportView,
     ObjectListView,
     ObjectView,
@@ -55,10 +56,11 @@ __all__ = (
     "ComponentCreateView",
     "ContentTypePermissionRequiredMixin",
     "csv_format",
     "EnhancedPage",
     "EnhancedPaginator",
+    "GenericView",
     "get_csv_form_fields_from_serializer_class",
     "get_paginate_count",
     "GetReturnURLMixin",
     "handle_protectederror",
     "NautobotHTMLRenderer",
diff --git a/nautobot/circuits/api/urls.py b/nautobot/circuits/api/urls.py
index a80830181e..4745216433 100644
--- a/nautobot/circuits/api/urls.py
+++ b/nautobot/circuits/api/urls.py
@@ -1,11 +1,10 @@
 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)
 
 # Circuits
diff --git a/nautobot/circuits/api/views.py b/nautobot/circuits/api/views.py
index 87903b60d9..0bef9ce8b6 100644
--- a/nautobot/circuits/api/views.py
+++ b/nautobot/circuits/api/views.py
@@ -1,25 +1,13 @@
-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
 from nautobot.dcim.api.views import PathEndpointMixin
 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
 #
 
 
diff --git a/nautobot/core/api/routers.py b/nautobot/core/api/routers.py
index 6a2e5d2950..a6470731a5 100644
--- a/nautobot/core/api/routers.py
+++ b/nautobot/core/api/routers.py
@@ -1,12 +1,25 @@
+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(
             {
                 "put": "bulk_update",
                 "patch": "bulk_partial_update",
@@ -18,9 +31,20 @@ class OrderedDefaultRouter(DefaultRouter):
         """
         Wrap DRF's DefaultRouter to return an alphabetized list of endpoints.
         """
         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)
diff --git a/nautobot/core/api/utils.py b/nautobot/core/api/utils.py
index 9068d7f167..f66f42584d 100644
--- a/nautobot/core/api/utils.py
+++ b/nautobot/core/api/utils.py
@@ -176,10 +176,14 @@ def get_view_name(view, suffix=None):
         name = view.queryset.model._meta.verbose_name
         name = " ".join([w[0].upper() + w[1:] for w in name.split()])  # Capitalize each word
 
     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")
         name = formatting.camelcase_to_spaces(name)
 
diff --git a/nautobot/core/api/views.py b/nautobot/core/api/views.py
index 3e01e50826..30e8bf1f68 100644
--- a/nautobot/core/api/views.py
+++ b/nautobot/core/api/views.py
@@ -20,13 +20,13 @@ from graphene_django.settings import graphene_settings
 from graphene_django.views import GraphQLView, HttpError, instantiate_middleware
 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
 from rest_framework.viewsets import ModelViewSet as ModelViewSet_, ReadOnlyModelViewSet as ReadOnlyModelViewSet_
 
@@ -339,19 +339,29 @@ class ReadOnlyModelViewSet(NautobotAPIVersionMixin, ModelViewSetMixin, ReadOnlyM
 #
 # Views
 #
 
 
-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
         return Response(
             OrderedDict(
@@ -490,15 +500,10 @@ class NautobotSpectacularSwaggerView(APIVersioningGetSchemaURLMixin, Spectacular
     template_name = "swagger_ui.html"
 
     @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)
 
         # drf-spectacular uses "settings" in the rendering context as a way to inject custom JavaScript if desired,
@@ -523,15 +528,16 @@ 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
     middleware = None
     root_value = None
diff --git a/nautobot/core/settings.py b/nautobot/core/settings.py
index 37a2c3a995..4117ee8084 100644
--- a/nautobot/core/settings.py
+++ b/nautobot/core/settings.py
@@ -240,10 +240,11 @@ SPECTACULAR_SETTINGS = {
     # in the schema.
     # OpenAPI 3.0 removes "basePath" in favor of "servers", so we now declare "/api" as the server relative URL and
     # 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
     "SWAGGER_UI_DIST": "SIDECAR",
     "SWAGGER_UI_FAVICON_HREF": "SIDECAR",
diff --git a/nautobot/core/tests/integration/test_view_authentication.py b/nautobot/core/tests/integration/test_view_authentication.py
new file mode 100644
index 0000000000..5fa93a242b
--- /dev/null
+++ b/nautobot/core/tests/integration/test_view_authentication.py
@@ -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)
+                        )
diff --git a/nautobot/core/tests/test_graphql.py b/nautobot/core/tests/test_graphql.py
index 5392aeb1ac..be435d7a74 100644
--- a/nautobot/core/tests/test_graphql.py
+++ b/nautobot/core/tests/test_graphql.py
@@ -628,25 +628,13 @@ class GraphQLAPIPermissionTest(GraphQLTestCaseBase):
         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"])
 
     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."""
         self.client.credentials(HTTP_AUTHORIZATION="Token zzzzzzzzzzabcdef0123456789abcdef01234567")
         response = self.client.post(self.api_url, {"query": self.get_racks_query}, format="json")
diff --git a/nautobot/core/tests/test_views.py b/nautobot/core/tests/test_views.py
index 496ee983d3..a0b19a1bc5 100644
--- a/nautobot/core/tests/test_views.py
+++ b/nautobot/core/tests/test_views.py
@@ -313,29 +313,35 @@ class LoginUI(TestCase):
     def test_sso_login_button_visible(self):
         self.client.logout()
         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):
     def query_and_parse_metrics(self):
         response = self.client.get(reverse("metrics"))
diff --git a/nautobot/core/utils/lookup.py b/nautobot/core/utils/lookup.py
index 669e48e7d7..593b3b49aa 100644
--- a/nautobot/core/utils/lookup.py
+++ b/nautobot/core/utils/lookup.py
@@ -1,14 +1,16 @@
 """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
 
 
 def get_changes_for_model(model):
     """
@@ -216,5 +218,127 @@ def get_created_and_last_updated_usernames_for_model(instance):
     last_updated_by_record = object_change_records.first()
     if last_updated_by_record:
         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
diff --git a/nautobot/core/views/__init__.py b/nautobot/core/views/__init__.py
index e36824fbc5..84fb964481 100644
--- a/nautobot/core/views/__init__.py
+++ b/nautobot/core/views/__init__.py
@@ -9,11 +9,11 @@ from django.apps import apps
 from django.conf import settings
 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
 from django.utils.encoding import smart_str
 from django.views.csrf import csrf_failure as _csrf_failure
@@ -208,11 +208,11 @@ class SearchView(AccessMixin, View):
                 "results": results,
             },
         )
 
 
-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.
     """
 
     def get(self, request):
@@ -263,16 +263,12 @@ def csrf_failure(request, reason="", template_name="403_csrf_failure.html"):
         "python_version": platform.python_version(),
     }
     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)
             data["editing"] = True
         data["saved_graphiql_queries"] = GraphQLQuery.objects.all()
diff --git a/nautobot/core/views/generic.py b/nautobot/core/views/generic.py
index e9761dc8ac..623db671c5 100644
--- a/nautobot/core/views/generic.py
+++ b/nautobot/core/views/generic.py
@@ -2,10 +2,11 @@ from copy import deepcopy
 import logging
 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,
     ObjectDoesNotExist,
     ValidationError,
@@ -55,10 +56,18 @@ from nautobot.core.views.utils import (
 )
 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.
 
     queryset: The base queryset for retrieving the object
diff --git a/nautobot/dcim/api/urls.py b/nautobot/dcim/api/urls.py
index c4719f308e..8bd1386a4d 100644
--- a/nautobot/dcim/api/urls.py
+++ b/nautobot/dcim/api/urls.py
@@ -1,11 +1,10 @@
 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)
 router.register("locations", views.LocationViewSet)
 
diff --git a/nautobot/dcim/api/views.py b/nautobot/dcim/api/views.py
index 2e523ce19f..e474027df6 100644
--- a/nautobot/dcim/api/views.py
+++ b/nautobot/dcim/api/views.py
@@ -11,11 +11,10 @@ from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema, OpenApiParameter
 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
 from nautobot.core.api.exceptions import ServiceUnavailable
 from nautobot.core.api.utils import get_serializer_for_model
@@ -67,20 +66,10 @@ from nautobot.ipam.models import Prefix, VLAN
 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
 
 
 class PathEndpointMixin:
     # TODO: the OpenAPI schema for this endpoint is wrong since it defaults to the same as "retrieve".
@@ -760,11 +749,11 @@ class ConnectedDeviceViewSet(ViewSet):
         if not peer_device_name or not peer_interface_name:
             raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
 
         # 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,
         )
         local_interface = peer_interface.connected_endpoint
 
diff --git a/nautobot/dcim/tests/test_api.py b/nautobot/dcim/tests/test_api.py
index 989775be3a..50148dce32 100644
--- a/nautobot/dcim/tests/test_api.py
+++ b/nautobot/dcim/tests/test_api.py
@@ -2090,11 +2090,14 @@ class ConnectedDeviceTest(APITestCase):
         cable.validated_save()
 
     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)
 
 
 class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
diff --git a/nautobot/dcim/views.py b/nautobot/dcim/views.py
index 583a1275e5..50e59a789d 100644
--- a/nautobot/dcim/views.py
+++ b/nautobot/dcim/views.py
@@ -9,11 +9,11 @@ from django.db.models import F, Prefetch
 from django.forms import (
     modelformset_factory,
     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
 from django_tables2 import RequestConfig
 
@@ -2314,11 +2314,11 @@ class CableCreateView(generic.ObjectEditView):
             "interface": forms.ConnectCableToInterfaceForm,
             "front-port": forms.ConnectCableToFrontPortForm,
             "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)
 
     def alter_obj(self, obj, request, url_args, url_kwargs):
         termination_a_type = url_kwargs.get("termination_a_type")
@@ -2331,10 +2331,13 @@ class CableCreateView(generic.ObjectEditView):
         obj.termination_b_type = self.termination_b_type
 
         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
         initial_data = {k: request.GET[k] for k in request.GET}
 
diff --git a/nautobot/docs/development/apps/api/views/core-view-overrides.md b/nautobot/docs/development/apps/api/views/core-view-overrides.md
index 643a86deaf..ab84413553 100644
--- a/nautobot/docs/development/apps/api/views/core-view-overrides.md
+++ b/nautobot/docs/development/apps/api/views/core-view-overrides.md
@@ -11,12 +11,17 @@ A simple example to override the device detail view:
 ```python
 # views.py
 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."))
 
 
diff --git a/nautobot/docs/development/apps/api/views/django-generic-views.md b/nautobot/docs/development/apps/api/views/django-generic-views.md
index 5fe0f671db..1a49fe9121 100644
--- a/nautobot/docs/development/apps/api/views/django-generic-views.md
+++ b/nautobot/docs/development/apps/api/views/django-generic-views.md
@@ -3,21 +3,25 @@
 The use of `generic` Django views can aid in app development. As an example, let's write a view which displays a random animal and the sound it makes. First, create the view in `views.py`:
 
 ```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):
         animal = Animal.objects.order_by('?').first()
         return render(request, 'nautobot_animal_sounds/animal.html', {
             'animal': animal,
         })
 ```
 
+!!! 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.
diff --git a/nautobot/extras/api/urls.py b/nautobot/extras/api/urls.py
index 6f10ef5e24..bc461bb95b 100644
--- a/nautobot/extras/api/urls.py
+++ b/nautobot/extras/api/urls.py
@@ -1,11 +1,10 @@
 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)
 
 # Config contexts
diff --git a/nautobot/extras/api/views.py b/nautobot/extras/api/views.py
index 920d365f4b..dfba74d005 100644
--- a/nautobot/extras/api/views.py
+++ b/nautobot/extras/api/views.py
@@ -14,11 +14,10 @@ from rest_framework import mixins, status, viewsets
 from rest_framework.decorators import action
 from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError
 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
 from nautobot.core.api.views import (
     BulkDestroyModelMixin,
@@ -72,19 +71,10 @@ from nautobot.extras.secrets.exceptions import SecretError
 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):
         """
         Apply "view" permissions on the POST /notes/ endpoint, otherwise as ModelViewSetMixin.
         """
diff --git a/nautobot/extras/plugins/views.py b/nautobot/extras/plugins/views.py
index 7ae0cb2cfe..b982303db5 100644
--- a/nautobot/extras/plugins/views.py
+++ b/nautobot/extras/plugins/views.py
@@ -1,10 +1,9 @@
 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
 from django.views.generic import View
 from django_tables2 import RequestConfig
@@ -12,12 +11,13 @@ from drf_spectacular.utils import extend_schema
 from rest_framework import permissions
 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
 
 
@@ -65,11 +65,11 @@ class InstalledPluginsView(AdminRequiredMixin, View):
                 "filter_form": None,
             },
         )
 
 
-class InstalledPluginDetailView(LoginRequiredMixin, View):
+class InstalledPluginDetailView(GenericView):
     """
     View for showing details of an installed plugin.
     """
 
     def get(self, request, plugin):
@@ -90,11 +90,10 @@ class InstalledPluginsAPIView(NautobotAPIVersionMixin, APIView):
     """
     API view for listing all installed non-core Apps.
     """
 
     permission_classes = [permissions.IsAdminUser]
-    _ignore_model_permissions = True
 
     def get_view_name(self):
         return "Installed Plugins"
 
     @staticmethod
@@ -126,15 +125,13 @@ class InstalledPluginsAPIView(NautobotAPIVersionMixin, APIView):
     @extend_schema(exclude=True)
     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_):
         # Check if the plugin specifies any API URLs
         api_app_name = f"{app_config.name}-api"
diff --git a/nautobot/extras/tests/test_views.py b/nautobot/extras/tests/test_views.py
index 5dd16f9674..9368c5d859 100644
--- a/nautobot/extras/tests/test_views.py
+++ b/nautobot/extras/tests/test_views.py
@@ -19,10 +19,11 @@ from nautobot.core.testing.utils import disable_warnings, post_data
 from nautobot.dcim.models import ConsolePort, Device, DeviceType, Interface, Location, LocationType, Manufacturer
 from nautobot.dcim.tests import test_views
 from nautobot.extras.choices import (
     CustomFieldTypeChoices,
     JobExecutionType,
+    LogLevelChoices,
     ObjectChangeActionChoices,
     SecretsGroupAccessTypeChoices,
     SecretsGroupSecretTypeChoices,
     WebhookHttpMethodChoices,
 )
@@ -38,10 +39,11 @@ from nautobot.extras.models import (
     ExternalIntegration,
     GitRepository,
     GraphQLQuery,
     Job,
     JobButton,
+    JobLogEntry,
     JobResult,
     Note,
     ObjectChange,
     Relationship,
     RelationshipAssociation,
@@ -615,10 +617,53 @@ class DynamicGroupTestCase(
             "dynamic_group_memberships-INITIAL_FORMS": "1",
             "dynamic_group_memberships-MIN_NUM_FORMS": "0",
             "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."""
         self.add_permissions("extras.add_dynamicgroup", "extras.change_dynamicgroup")
 
@@ -784,10 +829,38 @@ class GitRepositoryTestCase(
         form_data = self.form_data.copy()
         form_data["slug"] = instance.slug  # Slug is not editable
         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,
     ViewTestCases.DeleteObjectViewTestCase,
     ViewTestCases.EditObjectViewTestCase,
@@ -1501,10 +1574,38 @@ class JobResultTestCase(
 
     @classmethod
     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(
     # note no CreateObjectViewTestCase - we do not support user creation of Job records
     ViewTestCases.DeleteObjectViewTestCase,
diff --git a/nautobot/extras/views.py b/nautobot/extras/views.py
index 54d2b00f41..b648c688d4 100644
--- a/nautobot/extras/views.py
+++ b/nautobot/extras/views.py
@@ -711,11 +711,11 @@ class DynamicGroupBulkDeleteView(generic.BulkDeleteView):
     queryset = DynamicGroup.objects.all()
     table = tables.DynamicGroupTable
     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.
     """
 
@@ -910,22 +910,22 @@ def check_and_call_git_repository_function(request, pk, func):
     # Allow execution only if a worker process is running.
     if not get_worker_count():
         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)
 
 
 class GitRepositoryResultView(generic.ObjectView):
@@ -1557,19 +1557,19 @@ class JobResultView(generic.ObjectView):
             "associated_record": associated_record,
             "result": 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(
                 Q(message__icontains=filter_q) | Q(log_level__icontains=filter_q)
             )
@@ -1644,11 +1644,11 @@ class ObjectChangeView(generic.ObjectView):
             "related_changes_table": related_changes_table,
             "related_changes_count": related_changes.count(),
         }
 
 
-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.
     """
 
@@ -1728,11 +1728,11 @@ class NoteEditView(generic.ObjectEditView):
 
 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.
     """
 
@@ -1749,11 +1749,11 @@ class ObjectNotesView(View):
             initial={
                 "assigned_object_type": ContentType.objects.get_for_model(obj),
                 "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 = {
             "paginator_class": EnhancedPaginator,
             "per_page": get_paginate_count(request),
@@ -1970,11 +1970,11 @@ class SecretView(generic.ObjectView):
             "provider_name": provider.name if provider else instance.provider,
             "groups_table": groups_table,
         }
 
 
-class SecretProviderParametersFormView(View):
+class SecretProviderParametersFormView(generic.GenericView):
     """
     Helper view to SecretView; retrieve the HTML form appropriate for entering parameters for a given SecretsProvider.
     """
 
     def get(self, request, provider_slug):
diff --git a/nautobot/ipam/api/urls.py b/nautobot/ipam/api/urls.py
index 307a58a8c2..15b1c4cf1f 100644
--- a/nautobot/ipam/api/urls.py
+++ b/nautobot/ipam/api/urls.py
@@ -1,11 +1,10 @@
 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)
 
 # VRFs
diff --git a/nautobot/ipam/api/views.py b/nautobot/ipam/api/views.py
index 9016cdd770..8de9f7b986 100644
--- a/nautobot/ipam/api/views.py
+++ b/nautobot/ipam/api/views.py
@@ -3,11 +3,10 @@ from django.core.cache import cache
 from django.shortcuts import get_object_or_404
 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
 from nautobot.extras.api.views import NautobotModelViewSet
 from nautobot.ipam import filters
@@ -24,20 +23,10 @@ from nautobot.ipam.models import (
     VRF,
 )
 
 from . import serializers
 
-
-class IPAMRootView(APIRootView):
-    """
-    IPAM API root view
-    """
-
-    def get_view_name(self):
-        return "IPAM"
-
-
 #
 # Namespace
 #
 
 
diff --git a/nautobot/ipam/tests/test_graphql.py b/nautobot/ipam/tests/test_graphql.py
index 8c3f4a5b41..3fc140a7d1 100644
--- a/nautobot/ipam/tests/test_graphql.py
+++ b/nautobot/ipam/tests/test_graphql.py
@@ -1,18 +1,17 @@
-from django.test import override_settings
 from django.urls import reverse
 from rest_framework import status
 
 from nautobot.core.testing import APITestCase
 
 
 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 = """
         query {
             prefixes {
@@ -21,11 +20,11 @@ class TestPrefix(APITestCase):
                 ip_version
             }
         }
         """
         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)
         self.assertGreater(len(prefixes), 0)
 
diff --git a/nautobot/ipam/views.py b/nautobot/ipam/views.py
index 8f675c63a4..e78a6af9de 100644
--- a/nautobot/ipam/views.py
+++ b/nautobot/ipam/views.py
@@ -773,11 +773,11 @@ class IPAddressEditView(generic.ObjectEditView):
     def dispatch(self, request, *args, **kwargs):
         if "interface" in request.GET or "vminterface" in request.GET:
             _, 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)
 
     def successful_post(self, request, obj, created, _logger):
         """Check for data that will be invalid in a future Nautobot release and warn the user if found."""
@@ -856,21 +856,21 @@ class IPAddressAssignView(view_mixins.GetReturnURLMixin, generic.ObjectView):
     """
     Search for IPAddresses to be assigned to an Interface.
     """
 
     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)
 
     def get(self, request, *args, **kwargs):
         interface, _ = retrieve_interface_or_vminterface_from_request(request)
diff --git a/nautobot/tenancy/api/urls.py b/nautobot/tenancy/api/urls.py
index d94d00a3ed..49fd00e8ab 100644
--- a/nautobot/tenancy/api/urls.py
+++ b/nautobot/tenancy/api/urls.py
@@ -1,11 +1,10 @@
 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)
 router.register("tenants", views.TenantViewSet)
 
diff --git a/nautobot/tenancy/api/views.py b/nautobot/tenancy/api/views.py
index ad0e5063b8..81a16e57b3 100644
--- a/nautobot/tenancy/api/views.py
+++ b/nautobot/tenancy/api/views.py
@@ -1,7 +1,5 @@
-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
 from nautobot.extras.api.views import NautobotModelViewSet
 from nautobot.ipam.models import IPAddress, Prefix, VLAN, VRF
@@ -9,20 +7,10 @@ from nautobot.tenancy import filters
 from nautobot.tenancy.models import Tenant, TenantGroup
 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
 #
 
 
diff --git a/nautobot/users/api/urls.py b/nautobot/users/api/urls.py
index fb607b29c6..7febe574d8 100644
--- a/nautobot/users/api/urls.py
+++ b/nautobot/users/api/urls.py
@@ -1,11 +1,10 @@
 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)
 router.register("groups", views.GroupViewSet)
 
diff --git a/nautobot/users/api/views.py b/nautobot/users/api/views.py
index 9ec71891f4..55f0fb8364 100644
--- a/nautobot/users/api/views.py
+++ b/nautobot/users/api/views.py
@@ -1,16 +1,12 @@
-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
 from nautobot.core.api.views import ModelViewSet
 from nautobot.core.models.querysets import RestrictedQuerySet
@@ -18,64 +14,20 @@ from nautobot.core.utils.data import deepmerge
 from nautobot.users import filters
 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
 #
 
 
 class UserViewSet(ModelViewSet):
     queryset = RestrictedQuerySet(model=get_user_model()).prefetch_related("groups").order_by("username")
     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)),
 )
 class GroupViewSet(ModelViewSet):
@@ -99,25 +51,10 @@ class TokenViewSet(ModelViewSet):
     def authentication_classes(self):
         """Inherit default authentication_classes and basic authentication."""
         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.
         """
         queryset = super().get_queryset()
diff --git a/nautobot/users/views.py b/nautobot/users/views.py
index 769df6360b..cab9c52e39 100644
--- a/nautobot/users/views.py
+++ b/nautobot/users/views.py
@@ -5,21 +5,21 @@ from django.contrib.auth import (
     BACKEND_SESSION_KEY,
     login as auth_login,
     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
 from django.utils.decorators import method_decorator
 from django.utils.encoding import iri_to_uri
 from django.utils.http import url_has_allowed_host_and_scheme
 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
 
 #
@@ -116,11 +116,11 @@ class LogoutView(View):
 
 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):
         return render(
             request,
@@ -130,11 +130,11 @@ class ProfileView(LoginRequiredMixin, View):
                 "active_tab": "profile",
             },
         )
 
 
-class UserConfigView(LoginRequiredMixin, View):
+class UserConfigView(GenericView):
     template_name = "users/preferences.html"
 
     def get(self, request):
         return render(
             request,
@@ -158,11 +158,11 @@ class UserConfigView(LoginRequiredMixin, View):
         messages.success(request, "Your preferences have been updated.")
 
         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."
 
     def get(self, request):
@@ -216,11 +216,11 @@ class ChangePasswordView(LoginRequiredMixin, View):
 #
 # API tokens
 #
 
 
-class TokenListView(LoginRequiredMixin, View):
+class TokenListView(GenericView):
     def get(self, request):
         tokens = Token.objects.filter(user=request.user)
 
         return render(
             request,
@@ -231,11 +231,11 @@ class TokenListView(LoginRequiredMixin, View):
                 "is_django_auth_user": is_django_auth_user(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"):
                 return HttpResponseForbidden()
             token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
@@ -290,11 +290,11 @@ class TokenEditView(LoginRequiredMixin, View):
                 "editing": token.present_in_database,
             },
         )
 
 
-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 = {
             "return_url": reverse("user:token_list"),
         }
@@ -334,11 +334,11 @@ class TokenDeleteView(LoginRequiredMixin, View):
 #
 # Advanced Profile Settings
 #
 
 
-class AdvancedProfileSettingsEditView(LoginRequiredMixin, View):
+class AdvancedProfileSettingsEditView(GenericView):
     template_name = "users/advanced_settings_edit.html"
 
     def get(self, request):
         silk_record_requests = request.session.get("silk_record_requests", False)
         form = AdvancedProfileSettingsForm(initial={"request_profiling": silk_record_requests})
diff --git a/nautobot/virtualization/api/urls.py b/nautobot/virtualization/api/urls.py
index 5dbcdf15d3..51b3f3d3df 100644
--- a/nautobot/virtualization/api/urls.py
+++ b/nautobot/virtualization/api/urls.py
@@ -1,11 +1,10 @@
 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)
 router.register("cluster-groups", views.ClusterGroupViewSet)
 router.register("clusters", views.ClusterViewSet)
diff --git a/nautobot/virtualization/api/views.py b/nautobot/virtualization/api/views.py
index 282aee0f2b..74235e39b6 100644
--- a/nautobot/virtualization/api/views.py
+++ b/nautobot/virtualization/api/views.py
@@ -1,7 +1,5 @@
-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 (
     ConfigContextQuerySetMixin,
     ModelViewSet,
@@ -17,20 +15,10 @@ from nautobot.virtualization.models import (
     VMInterface,
 )
 
 from . import serializers
 
-
-class VirtualizationRootView(APIRootView):
-    """
-    Virtualization API root view
-    """
-
-    def get_view_name(self):
-        return "Virtualization"
-
-
 #
 # Clusters
 #
 
 

Vulnerable code: router = OrderedDefaultRouter()
from django.views.generic import View
class ExamplePluginHomeView(View):
class ExamplePluginConfigView(View):
class ViewToBeOverridden(View):
from django.views import generic
class ViewOverride(generic.View):
from nautobot.core.api.routers import OrderedDefaultRouter
    APIRootView,
router = OrderedDefaultRouter()
router.APIRootView = views.CircuitsRootView
from rest_framework.routers import APIRootView


class CircuitsRootView(APIRootView):
    """
    Circuits API root view
    """

    def get_view_name(self):
        return "Circuits"


    def __init__(self, *args, **kwargs):
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
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/locations/`.
    _ignore_model_permissions = True
    def get_view_name(self):
        return "API Root"
        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}")

    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]
        """Validate unauthenticated users are not able to query anything by default."""
        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"])
    @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."""
        urls = [reverse("api_docs"), reverse("graphql")]
            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)
from django.shortcuts import get_object_or_404, redirect, render
class StaticMediaFailureView(View):
class CustomGraphQLView(GraphQLView):
        if not request.user.is_authenticated:
            graphql_url = reverse("graphql")
            login_url = reverse(settings.LOGIN_URL)
            return redirect(f"{login_url}?next={graphql_url}")
router = OrderedDefaultRouter()
router.APIRootView = views.DCIMRootView
from rest_framework.routers import APIRootView

class DCIMRootView(APIRootView):
    """
    DCIM API root view
    """

    def get_view_name(self):
        return "DCIM"


            Interface.objects.all(),
from django.shortcuts import get_object_or_404, redirect, render
        }[kwargs.get("termination_b_type")]
class DeviceViewOverride(generic.View):
from django.views.generic import View
class RandomAnimalView(View):
router = OrderedDefaultRouter()
router.APIRootView = views.ExtrasRootView
from rest_framework.routers import APIRootView
class ExtrasRootView(APIRootView):
    """
    Extras API root view
    """

    def get_view_name(self):
        return "Extras"


from django.contrib.auth.mixins import LoginRequiredMixin
from nautobot.core.api.views import NautobotAPIVersionMixin
class InstalledPluginDetailView(LoginRequiredMixin, View):
    _ignore_model_permissions = True
class PluginsAPIRootView(NautobotAPIVersionMixin, APIView):
    _ignore_model_permissions = True

    def get_view_name(self):
        return "Plugins"
class ObjectDynamicGroupsView(View):
        repository = get_object_or_404(GitRepository, pk=pk)
class GitRepositorySyncView(View):
class GitRepositoryDryRunView(View):
class JobLogEntryTableView(View):
        instance = self.queryset.get(pk=pk)
class ObjectChangeLogView(View):
class ObjectNotesView(View):
        notes_table = tables.NoteTable(obj.notes)
class SecretProviderParametersFormView(View):
router = OrderedDefaultRouter()
router.APIRootView = views.IPAMRootView
from rest_framework.routers import APIRootView

class IPAMRootView(APIRootView):
    """
    IPAM API root view
    """

    def get_view_name(self):
        return "IPAM"


from django.test import override_settings
    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
        response = self.client.post(self.api_url, payload, format="json")
                return redirect(self.get_return_url(request), default_return_url="ipam:ipaddress_add")
    default_return_url = "ipam:ipaddress_add"
        # 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))
        _, error_msg = retrieve_interface_or_vminterface_from_request(request)
        if error_msg:
            messages.warning(request, error_msg)
            return redirect(self.get_return_url(request))
router = OrderedDefaultRouter()
router.APIRootView = views.TenancyRootView
from rest_framework.routers import APIRootView


class TenancyRootView(APIRootView):
    """
    Tenancy API root view
    """

    def get_view_name(self):
        return "Tenancy"


router = OrderedDefaultRouter()
router.APIRootView = views.UsersRootView
from django.contrib.auth import get_user_model, login, logout
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.routers import APIRootView

class UsersRootView(APIRootView):
    """
    Users API root view
    """

    def get_view_name(self):
        return "Users"


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

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

from django.contrib.auth.mixins import LoginRequiredMixin
class ProfileView(LoginRequiredMixin, View):
class UserConfigView(LoginRequiredMixin, View):
class ChangePasswordView(LoginRequiredMixin, View):
class TokenListView(LoginRequiredMixin, View):
class TokenEditView(LoginRequiredMixin, View):
class TokenDeleteView(LoginRequiredMixin, View):
class AdvancedProfileSettingsEditView(LoginRequiredMixin, View):
router = OrderedDefaultRouter()
router.APIRootView = views.VirtualizationRootView
from rest_framework.routers import APIRootView


class VirtualizationRootView(APIRootView):
    """
    Virtualization API root view
    """

    def get_view_name(self):
        return "Virtualization"


Fixed code: 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.
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.
Updated example views in the App developer documentation to include `ObjectPermissionRequiredMixin` or `LoginRequiredMixin` as appropriate best practices.
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.
Updated custom views in the `example_plugin` to use the new `GenericView` base class as a best practice.
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.
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))
router = OrderedDefaultRouter(view_name="Example App")
class ExamplePluginHomeView(views.GenericView):
class ExamplePluginConfigView(views.GenericView):
class ViewToBeOverridden(views.GenericView):
from nautobot.apps.views import GenericView

class ViewOverride(GenericView):
from nautobot.core.api.routers import AuthenticatedAPIRootView as APIRootView, OrderedDefaultRouter
    get_url_for_url_pattern,
    get_url_patterns,
    "get_url_for_url_pattern",
    "get_url_patterns",
    GenericView,
    "GenericView",
router = OrderedDefaultRouter(view_name="Circuits")
import logging

from nautobot.core.api.views import AuthenticatedAPIRootView

logger = logging.getLogger(__name__)

    APIRootView = AuthenticatedAPIRootView

    def __init__(self, *args, view_name=None, view_description=None, **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


        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__,
        )
        name = getattr(view, "name", None)
        if name is not None:
            return view.name

from rest_framework import routers, status
from rest_framework.permissions import IsAuthenticated
class AuthenticatedAPIRootView(NautobotAPIVersionMixin, routers.APIRootView):
    Extends DRF's base APIRootView class to enforce user authentication.
    permission_classes = [IsAuthenticated]

    name = None
    description = None


class APIRootView(AuthenticatedAPIRootView):
    """
    This is the root of the REST API.
    API endpoints are arranged by app and model name; e.g. `/api/dcim/locations/`.
    """

    name = "API Root"
    # 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]
    "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAuthenticated"],
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)
                        )
        """Validate unauthenticated users are not able to query anything."""
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
    def test_graphql_redirects_back_to_login_unauthenticated(self):
        """Assert that graphql redirects to login page if user is unauthenticated."""
        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"),
        ]
            response = self.client.get(url)
            self.assertHttpStatus(response, 403)
import re
from django.urls import get_resolver, URLPattern, URLResolver


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
from django.shortcuts import get_object_or_404, render
class StaticMediaFailureView(View):  # NOT using LoginRequiredMixin here as this may happen even on the login page
class CustomGraphQLView(LoginRequiredMixin, GraphQLView):
from django.contrib.auth.mixins import LoginRequiredMixin
class GenericView(LoginRequiredMixin, View):
    """
    Base class for non-object-related views.

    Enforces authentication, which Django's base View does not by default.
    """


router = OrderedDefaultRouter(view_name="DCIM")
            Interface.objects.restrict(request.user, "view"),
        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)
from django.shortcuts import get_object_or_404, HttpResponse, redirect, render
        }.get(kwargs.get("termination_b_type"), None)
        if self.model_form is None:
            return HttpResponse(status_code=400)

from nautobot.apps.views import ObjectPermissionRequiredMixin


class DeviceViewOverride(ObjectPermissionRequiredMixin, generic.View):
    def get_required_permission(self):
        return "dcim.view_device"

from nautobot.apps.views import GenericView
class RandomAnimalView(GenericView):
!!! 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.

router = OrderedDefaultRouter(view_name="Extras")
from nautobot.core.api.views import AuthenticatedAPIRootView, NautobotAPIVersionMixin
from nautobot.core.views.generic import GenericView
class InstalledPluginDetailView(GenericView):
class PluginsAPIRootView(AuthenticatedAPIRootView):
    name = "Apps"
    description = "API extension point for installed Nautobot Apps"
    LogLevelChoices,
    JobLogEntry,
    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)

    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

        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 ObjectDynamicGroupsView(generic.GenericView):
        repository = get_object_or_404(GitRepository.objects.restrict(request.user, "change"), pk=pk)
class GitRepositorySyncView(generic.GenericView):
class GitRepositoryDryRunView(generic.GenericView):
class JobLogEntryTableView(generic.GenericView):
        instance = get_object_or_404(self.queryset.restrict(request.user, "view"), pk=pk)
class ObjectChangeLogView(generic.GenericView):
class ObjectNotesView(generic.GenericView):
        notes_table = tables.NoteTable(obj.notes.restrict(request.user, "view"))
class SecretProviderParametersFormView(generic.GenericView):
router = OrderedDefaultRouter(view_name="IPAM")
        self.add_permissions("ipam.view_prefix")
        response = self.client.post(self.api_url, payload, format="json", **self.header)
                return redirect(self.get_return_url(request, default_return_url="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(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, default_return_url="ipam:ipaddress_add"))
router = OrderedDefaultRouter(view_name="Tenancy")
router = OrderedDefaultRouter(view_name="Users")
from django.contrib.auth import get_user_model
from rest_framework.permissions import IsAuthenticated
from nautobot.core.views.generic import GenericView
class ProfileView(GenericView):
class UserConfigView(GenericView):
class ChangePasswordView(GenericView):
class TokenListView(GenericView):
class TokenEditView(GenericView):
class TokenDeleteView(GenericView):
class AdvancedProfileSettingsEditView(GenericView):
router = OrderedDefaultRouter(view_name="Virtualization")