Browse Source

Fixes #23552: Restrict database writes in maintenance mode (#23664)

* Fixes #23552: Restrict database writes in maintenance mode

* 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.

* Skip UserConfig saves in maintenance mode, and give the maintenance mode page its own template
bctiemann 15 hours ago
parent
commit
f5efc27412

+ 14 - 1
docs/configuration/miscellaneous.md

@@ -178,7 +178,20 @@ The number of days to retain job results (scripts and reports). Set this to `0`
 
 Default: `False`
 
-Setting this to `True` will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled.
+Setting this to `True` places NetBox in a read-only state, and displays a "maintenance mode" banner (see [`BANNER_MAINTENANCE`](#banner_maintenance)) at the top of every page.
+
+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.
 
 ---
 

+ 0 - 8
netbox/account/views.py

@@ -8,8 +8,6 @@ from django.contrib.auth import logout as auth_logout
 from django.contrib.auth import update_session_auth_hash
 from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
 from django.contrib.auth.mixins import LoginRequiredMixin
-from django.contrib.auth.models import update_last_login
-from django.contrib.auth.signals import user_logged_in
 from django.http import HttpResponseRedirect, JsonResponse
 from django.shortcuts import get_object_or_404, redirect, render, resolve_url
 from django.urls import reverse, reverse_lazy
@@ -117,12 +115,6 @@ class LoginView(View):
         if form.is_valid():
             logger.debug("Login form validation was successful")
 
-            # If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
-            # last_login time upon authentication.
-            if get_config().MAINTENANCE_MODE:
-                logger.warning("Maintenance mode enabled: disabling update of most recent login time")
-                user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
-
             # Authenticate user
             auth_login(request, form.get_user())
             logger.info(f"User {request.user} successfully authenticated")

+ 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
 

+ 4 - 2
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'):

+ 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:

+ 20 - 7
netbox/netbox/middleware.py

@@ -6,10 +6,11 @@ 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 ProgrammingError, connection
+from django.db import Error, ProgrammingError, connection
 from django.db.utils import InternalError
-from django.http import Http404, HttpResponseRedirect
+from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware
+from django.template import loader
 from django.utils.translation import gettext_lazy as _
 from django_prometheus import middleware
 from social_django.middleware import SocialAuthExceptionMiddleware as SocialAuthExceptionMiddleware_
@@ -267,12 +268,20 @@ class MaintenanceModeMiddleware:
         self.get_response = get_response
 
     def __call__(self, request):
-        if get_config().MAINTENANCE_MODE:
-            self._set_session_type(
-                allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS)
-            )
+        if not get_config().MAINTENANCE_MODE or request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS):
+            return self.get_response(request)
 
-        return self.get_response(request)
+        # The read-only setting persists on the connection, so restore read/write access once the request has been
+        # handled. This also lets outer middleware (e.g. saving the session) write to the database.
+        self._set_session_type(allow_write=False)
+        try:
+            return self.get_response(request)
+        finally:
+            try:
+                self._set_session_type(allow_write=True)
+            except Error:
+                # Never leave a read-only connection to be reused by a later request
+                connection.close()
 
     @staticmethod
     def _set_session_type(allow_write):
@@ -297,6 +306,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 HttpResponse(loader.render_to_string('exceptions/maintenance_mode.html'), status=503)
+
             messages.error(request, error_message)
             return HttpResponseRedirect(request.path_info)
         return None

+ 4 - 3
netbox/netbox/settings.py

@@ -671,10 +671,11 @@ EXEMPT_EXCLUDE_MODELS = (
 
 # All URLs starting with a string listed here are exempt from maintenance mode enforcement
 MAINTENANCE_EXEMPT_PATHS = (
-    f'/{BASE_PATH}extras/config-revisions/',  # Allow modifying the configuration
+    f'/{BASE_PATH}core/config-revisions/',  # Allow modifying the configuration
     LOGIN_URL,
-    LOGIN_REDIRECT_URL,
-    LOGOUT_REDIRECT_URL
+    f'/{BASE_PATH}logout/',
+    f'/{BASE_PATH}oauth/',  # Single sign-on
+    f'/{BASE_PATH}api/users/tokens/provision/',  # Allow provisioning API tokens
 )
 
 

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

@@ -4,13 +4,23 @@ from contextlib import contextmanager
 from types import SimpleNamespace
 from unittest.mock import Mock, patch
 
+from django.contrib.messages import get_messages
+from django.core.cache import cache
 from django.core.signals import got_request_exception
-from django.db.utils import InternalError
-from django.test import RequestFactory, override_settings
+from django.db import connection
+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.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
+from users.models import Token, User
 from utilities.testing import TestCase, disable_warnings
 
 
@@ -141,6 +151,29 @@ 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, 503)
+        self.assertIn('maintenance mode', response.content.decode())
+        self.assertNotIn('Server Error', response.content.decode())
+
     def test_maintenance_mode_handles_graphql_json_request(self):
         request = self.factory.post(
             reverse('graphql'),
@@ -159,6 +192,267 @@ class CoreMiddlewareTestCase(TestCase):
         self.assert_json_500_response(response)
 
 
+def is_read_only():
+    with connection.cursor() as cursor:
+        cursor.execute('SHOW transaction_read_only')
+        return cursor.fetchone()[0] == 'on'
+
+
+@override_settings(MAINTENANCE_MODE=True)
+class MaintenanceModeTestCase(TransactionTestCase):
+    """
+    Maintenance mode is enforced on the database session, which has no effect on writes within the transaction that
+    wraps each TestCase test, so these tests run against a real connection.
+    """
+
+    def setUp(self):
+        self.user = User.objects.create_user(username='testuser', password='testpassword', is_superuser=True)
+
+    def tearDown(self):
+        # Don't leave a read-only connection behind for other tests
+        with connection.cursor() as cursor:
+            cursor.execute('SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE')
+
+    def get_read_only_during_request(self, path):
+        """
+        Return whether the database connection was read-only while handling a request for the given path.
+        """
+        read_only = []
+
+        def get_response(request):
+            read_only.append(is_read_only())
+
+        MaintenanceModeMiddleware(get_response)(RequestFactory().get(path))
+        return read_only[0]
+
+    def test_exempt_paths(self):
+        exempt_paths = (
+            reverse('login'),
+            reverse('logout'),
+            reverse('social:begin', kwargs={'backend': 'github'}),
+            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):
+                self.assertFalse(self.get_read_only_during_request(path))
+                self.assertFalse(is_read_only())
+
+    def test_restricted_paths(self):
+        restricted_paths = (
+            reverse('home'),
+            reverse('dcim:site_add'),
+            reverse('account:preferences'),
+            reverse('api-root'),
+            reverse('graphql'),
+        )
+        for path in restricted_paths:
+            with self.subTest(path=path):
+                self.assertTrue(self.get_read_only_during_request(path))
+                self.assertFalse(is_read_only(), "Connection was left read-only after the request")
+
+    def test_maintenance_mode_disabled(self):
+        with override_settings(MAINTENANCE_MODE=False):
+            self.assertFalse(self.get_read_only_during_request(reverse('dcim:site_add')))
+
+    def test_ui_write_refused(self):
+        self.client.force_login(self.user)
+        url = reverse('dcim:site_add')
+
+        response = self.client.post(url, {'name': 'Site 1', 'slug': 'site-1', 'status': 'active'})
+
+        self.assertRedirects(response, url, fetch_redirect_response=False)
+        self.assertIn('maintenance mode', str(list(get_messages(response.wsgi_request))[0]))
+        self.assertFalse(Site.objects.exists())
+
+    def test_api_write_refused(self):
+        token = Token.objects.create(user=self.user)
+        header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
+
+        response = self.client.post(
+            reverse('dcim-api:site-list'),
+            {'name': 'Site 1', 'slug': 'site-1', 'status': 'active'},
+            content_type='application/json',
+            **header,
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+        self.assertIn('maintenance mode', response.json()['error'])
+        self.assertFalse(Site.objects.exists())
+
+    def test_api_user_config_write_refused(self):
+        """
+        Explicitly saving a user's preferences is refused, unlike the incidental changes made while viewing a page.
+        """
+        token = Token.objects.create(user=self.user)
+        header = {'HTTP_AUTHORIZATION': f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'}
+
+        response = self.client.patch(
+            reverse('users-api:userconfig-list'),
+            {'data_format': 'yaml'},
+            content_type='application/json',
+            **header,
+        )
+
+        self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR)
+        self.assertIn('maintenance mode', response.json()['error'])
+        self.user.config.refresh_from_db()
+        self.assertIsNone(self.user.config.get('data_format'))
+
+    def test_writes_resume_when_maintenance_mode_disabled(self):
+        self.client.force_login(self.user)
+        data = {'name': 'Site 1', 'slug': 'site-1', 'status': 'active'}
+        self.client.post(reverse('dcim:site_add'), data)
+
+        with override_settings(MAINTENANCE_MODE=False):
+            response = self.client.post(reverse('dcim:site_add'), data)
+
+        self.assertEqual(response.status_code, 302)
+        self.assertTrue(Site.objects.filter(slug='site-1').exists())
+
+    @override_settings(SESSION_SAVE_EVERY_REQUEST=True)
+    def test_session_saved_on_every_request(self):
+        """
+        LOGIN_PERSISTENCE saves the session on every request.
+        """
+        self.client.force_login(self.user)
+
+        response = self.client.get(reverse('dcim:site_list'))
+
+        self.assertEqual(response.status_code, 200)
+
+    def test_login_and_logout(self):
+        response = self.client.post(reverse('login'), {'username': 'testuser', 'password': 'testpassword'})
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(int(self.client.session['_auth_user_id']), self.user.pk)
+
+        # The login time isn't recorded in maintenance mode
+        self.user.refresh_from_db()
+        self.assertIsNone(self.user.last_login)
+
+        response = self.client.get(reverse('logout'))
+        self.assertEqual(response.status_code, 302)
+        self.assertNotIn('_auth_user_id', self.client.session)
+
+        # The login time is recorded once maintenance mode is disabled
+        with override_settings(MAINTENANCE_MODE=False):
+            self.client.post(reverse('login'), {'username': 'testuser', 'password': 'testpassword'})
+        self.user.refresh_from_db()
+        self.assertIsNotNone(self.user.last_login)
+
+    @override_settings(REMOTE_AUTH_ENABLED=True, LOGIN_REQUIRED=True)
+    def test_remote_auth_login(self):
+        User.objects.create(username='remoteuser1')
+
+        # A refused write must not leave the connection read-only for the next request
+        self.client.force_login(self.user)
+        self.client.post(reverse('dcim:site_add'), {'name': 'Site 1', 'slug': 'site-1', 'status': 'active'})
+        self.client.logout()
+
+        response = self.client.get(reverse('home'), follow=True, HTTP_REMOTE_USER='remoteuser1')
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(self.client.session['_auth_user_id'], str(User.objects.get(username='remoteuser1').pk))
+
+    # Use a per-process cache, so that activating the new revision doesn't affect other tests
+    @override_settings(CACHES={
+        'default': {
+            'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+            'LOCATION': 'netbox-maintenance-mode-tests',
+        },
+    })
+    def test_modify_configuration(self):
+        self.addCleanup(cache.clear)
+        self.addCleanup(clear_config)
+        self.client.force_login(self.user)
+
+        response = self.client.post(reverse('core:configrevision_add'), {'comment': 'Disable maintenance mode'})
+
+        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()
+
+
 @override_settings(SENTRY_ENABLED=True)
 class SentryNotFoundReportingTestCase(TestCase):
 

+ 2 - 1
netbox/templates/500.html

@@ -5,6 +5,7 @@
 
 <head>
   <title>{% trans "Server Error" %}</title>
+  <link rel="stylesheet" href="{% static 'netbox-external.css' %}" />
   <link rel="stylesheet" href="{% static 'netbox.css'%}" />
   <meta charset="UTF-8">
 </head>
@@ -15,7 +16,7 @@
       <div class="col col-md-6 offset-md-3">
         <div class="card border-danger mt-5">
           <h2 class="card-header text-bg-danger">
-            <i class="mdi mdi-alert"></i> {% trans "Server Error" %}
+            <i class="mdi mdi-alert me-1"></i> {% trans "Server Error" %}
           </h2>
           <div class="card-body">
             {% block message %}

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

@@ -0,0 +1,35 @@
+{% load i18n %}
+{% load static %}
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <title>{% trans "Maintenance Mode" %}</title>
+  <link rel="stylesheet" href="{% static 'netbox-external.css' %}" />
+  <link rel="stylesheet" href="{% static 'netbox.css'%}" />
+  <meta charset="UTF-8">
+</head>
+
+<body>
+  <div class="container-fluid">
+    <div class="row">
+      <div class="col col-md-6 offset-md-3">
+        <div class="card border-warning mt-5">
+          <h2 class="card-header text-bg-warning">
+            <i class="mdi mdi-wrench me-1"></i> {% trans "Maintenance Mode" %}
+          </h2>
+          <div class="card-body">
+            <p>
+              {% trans "NetBox is currently operating in maintenance mode and is unable to perform write operations. Please try again later." %}
+            </p>
+            <div class="text-end">
+              <a href="{% url 'home' %}" class="btn btn-primary">{% trans "Home Page" %}</a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</body>
+
+</html>

+ 12 - 4
netbox/users/models/preferences.py

@@ -81,7 +81,8 @@ class UserConfig(models.Model):
 
         :param path: Dotted path to the configuration key. For example, 'foo.bar' sets self.data['foo']['bar'].
         :param value: The value to be written. This can be any type supported by JSON.
-        :param commit: If true, the UserConfig instance will be saved once the new value has been applied.
+        :param commit: If true, the UserConfig instance will be saved once the new value has been applied. (The save
+            is skipped while maintenance mode is enabled, since the database may be read-only.)
         """
         d = self.data
         keys = path.split('.')
@@ -112,7 +113,7 @@ class UserConfig(models.Model):
             d[key] = value
 
         if commit:
-            self.save()
+            self._commit()
     set.alters_data = True
 
     def clear(self, path, commit=False):
@@ -125,7 +126,8 @@ class UserConfig(models.Model):
         Invalid keys will be ignored silently.
 
         :param path: Dotted path to the configuration key. For example, 'foo.bar' deletes self.data['foo']['bar'].
-        :param commit: If true, the UserConfig instance will be saved once the new value has been applied.
+        :param commit: If true, the UserConfig instance will be saved once the new value has been applied. (The save
+            is skipped while maintenance mode is enabled, since the database may be read-only.)
         """
         d = self.data
         keys = path.split('.')
@@ -140,5 +142,11 @@ class UserConfig(models.Model):
         d.pop(key, None)  # Avoid a KeyError on invalid keys
 
         if commit:
-            self.save()
+            self._commit()
     clear.alters_data = True
+
+    def _commit(self):
+        # Don't save the change while maintenance mode is enabled: the database may be read-only, and these changes
+        # are typically incidental to a read-only request (e.g. a table's ordering).
+        if not get_config().MAINTENANCE_MODE:
+            self.save()

+ 18 - 0
netbox/users/signals.py

@@ -1,6 +1,7 @@
 import copy
 import logging
 
+from django.contrib.auth.models import update_last_login as django_update_last_login
 from django.contrib.auth.signals import user_logged_in, user_login_failed
 from django.db.models.signals import post_save
 from django.dispatch import receiver
@@ -24,6 +25,23 @@ def log_user_login_failed(sender, credentials, request, **kwargs):
         logger.info(f"Failed login attempt for username: {username}")
 
 
+# Replace Django's receiver, which always records the login time
+user_logged_in.disconnect(dispatch_uid='update_last_login')
+
+
+@receiver(user_logged_in, dispatch_uid='update_last_login')
+def update_last_login(sender, user, **kwargs):
+    """
+    Record the user's login time, unless maintenance mode is enabled. This allows users to log in while the database
+    is read-only, whichever authentication method they use.
+    """
+    if get_config().MAINTENANCE_MODE:
+        logger = logging.getLogger('netbox.auth.login')
+        logger.debug("Maintenance mode enabled: disabling update of most recent login time")
+        return
+    django_update_last_login(sender, user, **kwargs)
+
+
 @receiver(user_logged_in)
 def set_language_on_login(sender, user, request, **kwargs):
     """

+ 14 - 0
netbox/users/tests/test_models.py

@@ -233,6 +233,20 @@ class UserConfigTestCase(TestCase):
         # Clear a non-existing value; should fail silently
         userconfig.clear('invalid')
 
+    @override_settings(MAINTENANCE_MODE=True)
+    def test_commit_skipped_in_maintenance_mode(self):
+        userconfig = User.objects.get(username='testuser').config
+
+        # The change is applied, but not saved
+        userconfig.set('a', 'def', commit=True)
+        userconfig.clear('b.foo', commit=True)
+        self.assertEqual(userconfig.data['a'], 'def')
+        self.assertNotIn('foo', userconfig.data['b'])
+
+        userconfig.refresh_from_db()
+        self.assertEqual(userconfig.data['a'], True)
+        self.assertEqual(userconfig.data['b']['foo'], 101)
+
 
 class RestrictedQuerySetTestCase(TestCase):
     """