Просмотр исходного кода

Skip incidental writes on read-only requests in maintenance mode

Don't save table, table config and data format preferences, mark
notifications read, create a default dashboard or update LDAP users
while maintenance mode is enabled. Render an error page instead of
redirecting a failed GET request back to itself, close the connection
on any database error when restoring read/write access, and exempt
API token provisioning.
Brian Tiemann 20 минут назад
Родитель
Сommit
934f2bbb7b

+ 3 - 0
docs/configuration/miscellaneous.md

@@ -183,10 +183,13 @@ Setting this to `True` places NetBox in a read-only state, and displays a "maint
 While maintenance mode is enabled, any request which attempts to write to the database through the web UI or the REST API fails with an error message. The following remain available:
 
 * Logging in and out, including via single sign-on and remote authentication
+* Provisioning API tokens via the REST API
 * Modifying the configuration under Admin > Configuration History, so that maintenance mode can be disabled again from the UI (unless it has been set in `configuration.py`)
 
 Additionally, NetBox will not update a user's "last login" time, or a token's "last used" time, while maintenance mode is enabled. This allows users to log in when the database itself is read-only. Note that logging in also saves the user's session, which is stored in the database unless [`SESSION_FILE_PATH`](./security.md#session_file_path) is set.
 
+User preferences that are normally saved while browsing (such as table ordering) apply only to the current request, and viewing a notification does not mark it as read.
+
 !!! note
     Maintenance mode applies only to web UI and API requests. It does not prevent background jobs (such as scheduled scripts, data source synchronization, and housekeeping tasks) or management commands from writing to the database.
 

+ 4 - 2
netbox/extras/dashboard/utils.py

@@ -5,6 +5,7 @@ from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext as _
 
 from extras.constants import DEFAULT_DASHBOARD
+from netbox.config import get_config
 from netbox.registry import registry
 
 __all__ = (
@@ -46,10 +47,11 @@ def get_dashboard(user):
         try:
             dashboard = user.dashboard
         except ObjectDoesNotExist:
-            # Create a dashboard for this user
+            # Create a dashboard for this user (unless the database is read-only)
             dashboard = get_default_dashboard()
             dashboard.user = user
-            dashboard.save()
+            if not get_config().MAINTENANCE_MODE:
+                dashboard.save()
 
     return dashboard
 

+ 6 - 4
netbox/extras/views.py

@@ -26,6 +26,7 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from extras.scripts import prepare_script_form
 from extras.utils import SharedObjectViewMixin
+from netbox.config import get_config
 from netbox.object_actions import *
 from netbox.ui import layout
 from netbox.ui.panels import (
@@ -649,8 +650,9 @@ class NotificationReadView(LoginRequiredMixin, View):
     def get(self, request, pk):
         # Mark the Notification as read
         notification = get_object_or_404(request.user.notifications, pk=pk)
-        notification.read = timezone.now()
-        notification.save()
+        if not get_config().MAINTENANCE_MODE:
+            notification.read = timezone.now()
+            notification.save()
 
         # Redirect to the object if it has a URL (deleted objects will not)
         if hasattr(notification.object, 'get_absolute_url'):
@@ -1112,7 +1114,7 @@ class ConfigContextView(generic.ObjectView):
         if request.GET.get('format') in ['json', 'yaml']:
             format = request.GET.get('format')
             if request.user.is_authenticated:
-                request.user.config.set('data_format', format, commit=True)
+                request.user.config.set('data_format', format, commit=not get_config().MAINTENANCE_MODE)
         elif request.user.is_authenticated:
             format = request.user.config.get('data_format', 'json')
         else:
@@ -1174,7 +1176,7 @@ class ObjectConfigContextView(generic.ObjectView):
         if request.GET.get('format') in ['json', 'yaml']:
             format = request.GET.get('format')
             if request.user.is_authenticated:
-                request.user.config.set('data_format', format, commit=True)
+                request.user.config.set('data_format', format, commit=not get_config().MAINTENANCE_MODE)
         elif request.user.is_authenticated:
             format = request.user.config.get('data_format', 'json')
         else:

+ 5 - 1
netbox/netbox/api/authentication.py

@@ -110,7 +110,11 @@ class TokenAuthentication(BaseAuthentication):
 
             # Load from LDAP if FIND_GROUP_PERMS is active
             # Always query LDAP when user is not active, otherwise it is never activated again
-            if ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
+            # Skip this in maintenance mode, as it updates the user in the database
+            if get_config().MAINTENANCE_MODE:
+                logger = logging.getLogger('netbox.auth.login')
+                logger.debug("Maintenance mode enabled: Disabling update of user from LDAP directory")
+            elif ldap_backend.settings.FIND_GROUP_PERMS or not token.user.is_active:
                 ldap_user = ldap_backend.populate_user(token.user.username)
                 # If the user is found in the LDAP directory use it, if not fallback to the local user
                 if ldap_user:

+ 6 - 2
netbox/netbox/middleware.py

@@ -6,7 +6,7 @@ from django.contrib import auth, messages
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.core.exceptions import ImproperlyConfigured
 from django.core.signals import got_request_exception
-from django.db import DatabaseError, ProgrammingError, connection
+from django.db import Error, ProgrammingError, connection
 from django.db.utils import InternalError
 from django.http import Http404, HttpResponseRedirect
 from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
@@ -267,7 +267,7 @@ class MaintenanceModeMiddleware:
         finally:
             try:
                 self._set_session_type(allow_write=True)
-            except DatabaseError:
+            except Error:
                 # Never leave a read-only connection to be reused by a later request
                 connection.close()
 
@@ -294,6 +294,10 @@ class MaintenanceModeMiddleware:
             if is_api_request(request) or is_graphql_request(request):
                 return handle_rest_api_exception(request, error=error_message)
 
+            # Redirecting a read-only request (e.g. GET) back to the same URL would fail the same way again
+            if request.method in ('GET', 'HEAD', 'OPTIONS'):
+                return handler_500(request, template_name='exceptions/maintenance_mode.html')
+
             messages.error(request, error_message)
             return HttpResponseRedirect(request.path_info)
         return None

+ 1 - 0
netbox/netbox/settings.py

@@ -675,6 +675,7 @@ MAINTENANCE_EXEMPT_PATHS = (
     LOGIN_URL,
     f'/{BASE_PATH}logout/',
     f'/{BASE_PATH}oauth/',  # Single sign-on
+    f'/{BASE_PATH}api/users/tokens/provision/',  # Allow provisioning API tokens
 )
 
 

+ 5 - 2
netbox/netbox/tables/tables.py

@@ -17,6 +17,7 @@ from django_tables2.utils import OrderBy
 from core.models import ObjectType
 from extras.choices import *
 from extras.models import CustomField, CustomLink
+from netbox.config import get_config
 from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.registry import registry
 from netbox.tables import columns
@@ -227,14 +228,16 @@ class BaseTable(tables.Table):
         ordering = None
 
         if request.user.is_authenticated and self.prefixed_order_by_field in request.GET:
+            # Don't save the preference while the database is read-only (maintenance mode)
+            commit = not get_config().MAINTENANCE_MODE
             if request.GET[self.prefixed_order_by_field]:
                 # If an ordering has been specified as a query parameter, save it as the
                 # user's preferred ordering for this table.
                 ordering = request.GET.getlist(self.prefixed_order_by_field)
-                request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
+                request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=commit)
             else:
                 # If the ordering has been set to none (empty), clear any existing preference.
-                request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
+                request.user.config.clear(f'tables.{self.name}.ordering', commit=commit)
 
         # If the user has a saved preference, apply it
         if request.user.is_authenticated and (userconfig := request.user.config):

+ 106 - 2
netbox/netbox/tests/test_middleware.py

@@ -7,13 +7,15 @@ from django.contrib.messages import get_messages
 from django.core.cache import cache
 from django.core.signals import got_request_exception
 from django.db import connection
-from django.db.utils import InternalError
+from django.db.utils import InterfaceError, InternalError
 from django.test import RequestFactory, TransactionTestCase, override_settings
 from django.urls import reverse
 from rest_framework import status
 
-from core.models import ConfigRevision
+from core.events import OBJECT_CREATED
+from core.models import ConfigRevision, ObjectType
 from dcim.models import Site
+from extras.models import ConfigContext, Dashboard, Notification, TableConfig
 from netbox.config import clear_config
 from netbox.middleware import CoreMiddleware, MaintenanceModeMiddleware
 from users.constants import TOKEN_PREFIX
@@ -148,6 +150,28 @@ class CoreMiddlewareTestCase(TestCase):
 
         self.assert_json_500_response(response)
 
+    def test_maintenance_mode_handles_ui_post_request(self):
+        request = self.factory.post('/dcim/sites/add/')
+        request._messages = Mock()
+
+        with patch('netbox.middleware.get_config', return_value=SimpleNamespace(MAINTENANCE_MODE=True)):
+            response = self.process_internal_error(request, 'Simulated maintenance mode UI error')
+
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response.url, '/dcim/sites/add/')
+
+    def test_maintenance_mode_handles_ui_get_request(self):
+        """
+        A failed write during a GET request must not redirect back to the same URL, which would fail again.
+        """
+        request = self.factory.get('/')
+
+        with patch('netbox.middleware.get_config', return_value=SimpleNamespace(MAINTENANCE_MODE=True)):
+            response = self.process_internal_error(request, 'Simulated maintenance mode UI error')
+
+        self.assertEqual(response.status_code, 500)
+        self.assertIn('maintenance mode', response.content.decode())
+
     def test_maintenance_mode_handles_graphql_json_request(self):
         request = self.factory.post(
             reverse('graphql'),
@@ -207,6 +231,7 @@ class MaintenanceModeTestCase(TransactionTestCase):
             reverse('social:complete', kwargs={'backend': 'github'}),
             reverse('core:configrevision_list'),
             reverse('core:configrevision_add'),
+            reverse('users-api:token_provision'),
         )
         for path in exempt_paths:
             with self.subTest(path=path):
@@ -326,3 +351,82 @@ class MaintenanceModeTestCase(TransactionTestCase):
 
         self.assertEqual(response.status_code, 302)
         self.assertTrue(ConfigRevision.objects.filter(comment='Disable maintenance mode').exists())
+
+    def test_connection_closed_if_restore_fails(self):
+        middleware = MaintenanceModeMiddleware(lambda request: None)
+
+        # Simulate losing the connection before read/write access is restored
+        with (
+            patch.object(MaintenanceModeMiddleware, '_set_session_type', side_effect=[None, InterfaceError]),
+            patch('netbox.middleware.connection') as mock_connection,
+        ):
+            middleware(RequestFactory().get(reverse('home')))
+
+        mock_connection.close.assert_called_once()
+
+    def test_table_ordering_not_saved(self):
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('dcim:site_list'), {'sort': 'name'})
+
+        self.assertEqual(response.status_code, 200)
+        self.user.config.refresh_from_db()
+        self.assertIsNone(self.user.config.get('tables.SiteTable.ordering'))
+
+    def test_table_config_not_saved(self):
+        tableconfig = TableConfig.objects.create(
+            name='Table Config 1',
+            object_type=ObjectType.objects.get_for_model(Site),
+            table='SiteTable',
+            user=self.user,
+            columns=['name', 'status'],
+            ordering=['name'],
+        )
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('dcim:site_list'), {'tableconfig_id': tableconfig.pk})
+
+        self.assertEqual(response.status_code, 200)
+        self.user.config.refresh_from_db()
+        self.assertIsNone(self.user.config.get('tables.SiteTable.columns'))
+
+    def test_data_format_not_saved(self):
+        configcontext = ConfigContext.objects.create(name='Config Context 1', data={'foo': 'bar'})
+        self.client.force_login(self.user)
+
+        response = self.client.get(configcontext.get_absolute_url(), {'format': 'yaml'})
+
+        self.assertEqual(response.status_code, 200)
+        self.user.config.refresh_from_db()
+        self.assertIsNone(self.user.config.get('data_format'))
+
+    def test_default_dashboard_not_saved(self):
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('home'))
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(list(get_messages(response.wsgi_request)), [])
+        self.assertFalse(Dashboard.objects.filter(user=self.user).exists())
+
+    def test_notification_not_marked_read(self):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        notification = Notification.objects.create(user=self.user, object=site, event_type=OBJECT_CREATED)
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('extras:notification_read', kwargs={'pk': notification.pk}))
+
+        self.assertRedirects(response, site.get_absolute_url(), fetch_redirect_response=False)
+        notification.refresh_from_db()
+        self.assertIsNone(notification.read)
+
+    @override_settings(REMOTE_AUTH_BACKEND=['netbox.authentication.LDAPBackend'])
+    def test_api_ldap_user_not_updated(self):
+        token = Token.objects.create(user=self.user)
+        header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
+
+        with patch('netbox.authentication.LDAPBackend') as mock_backend:
+            response = self.client.get(reverse('dcim-api:site-list'), **header)
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        mock_backend.return_value.populate_user.assert_not_called()

+ 4 - 1
netbox/netbox/views/generic/mixins.py

@@ -1,6 +1,7 @@
 from django.shortcuts import get_object_or_404
 
 from extras.models import TableConfig
+from netbox.config import get_config
 from utilities.permissions import get_permission_for_model
 
 __all__ = (
@@ -56,7 +57,9 @@ class TableMixin:
             if request.user.is_authenticated:
                 table = self.table.__name__
                 request.user.config.set(f'tables.{table}.columns', tableconfig.columns)
-                request.user.config.set(f'tables.{table}.ordering', tableconfig.ordering, commit=True)
+                request.user.config.set(
+                    f'tables.{table}.ordering', tableconfig.ordering, commit=not get_config().MAINTENANCE_MODE
+                )
 
         table = self.table(data)
         if 'pk' in table.base_columns and bulk_actions:

+ 8 - 0
netbox/templates/exceptions/maintenance_mode.html

@@ -0,0 +1,8 @@
+{% extends '500.html' %}
+{% load i18n %}
+
+{% block message %}
+  <p>
+    {% trans "NetBox is currently operating in maintenance mode and is unable to perform write operations. Please try again later." %}
+  </p>
+{% endblock message %}