Repository: netbox-community/netbox
Pull request: Adds db read-only middleware
<!--
    Thank you for your interest in contributing to NetBox! Please note that
    our contribution policy requires that a feature request or bug report be
    approved and assigned prior to opening a pull request. This helps avoid
    waste time and effort on a proposed change that we might not be able to
    accept.

    IF YOUR PULL REQUEST DOES NOT REFERENCE AN ISSUE WHICH HAS BEEN ASSIGNED
    TO YOU, IT WILL BE CLOSED AUTOMATICALLY.

    Please specify your assigned issue number on the line below.
-->
### Fixes: #11233

<!--
    Please include a summary of the proposed changes below.
-->

![image](https://user-images.githubusercontent.com/5083532/236318850-f0050239-0daa-4f97-9aee-d7196f18cbba.png)



Problem statement:
Webfrontend with database in readonly mode
### NetBox version

v3.3.9

### Feature type

Change to existing functionality

### Proposed functionality

The current 'Maintenance mode' ensures that no writes are done to the database. It also displays a banner that the instance is in maintenance mode.

I would like to have a 'ReadOnly' mode that
- like maintenance mode, prevents updates by logins.
- don't display a banner (custom banner can be set with the BANNER_TOP)
- don't do any database updates with the upgrade.sh script
- optionally: provide a message to the user if he tries to do a write operation, with info to the read/write
- optionally: use a separate database for session logs


### Use case

We use postgres WAL to create a almost-realtime readonly copy of our production database.
This can be used to poll data from with API calls and the GUI.

In order to maintain this node, we need to patch the upgrade.sh (remove database operation), but that breaks the git pull.
Also, that banner that is show is not relevant to the state of our instance. It is not in maintenance, it is in readonly.
The sessionpath is an option, but I would rather like to send that to a separate database (different postgresql server) to maintain the session state across webfrontends

### Database changes

_No response_

### External dependencies

_No response_

Hints from issue discussion:
I'd really like to see this as well, however I'm not sure if we can elegantly hook into the database connection to catch and prevent write operations cleanly. Anyone have any ideas?
Same case here, we are using postgres streaming replication and setup up 2 instances of Netbox, It would be cool if user can login (we are using LDAP for remote auth) to replica instance of Netbox as read-only.

I'm still seeing logs after turning MAINTENANCE_MODE=True,  is it not supposed to update the last login etc... ?  v3.3.7
![Screenshot 2023-01-10 123633](https://user-images.githubusercontent.com/40869474/211490839-813ca562-3841-4519-9b76-6d963fbb9276.png)






@talha700 - https://django-auth-ldap.readthedocs.io/en/latest/reference.html#auth-ldap-always-update-user
@jeremystretch Can I take a try at this? I have a solution which involves overriding `CursorWrapper` to raise exception in maintenance mode which in turn disallows write operations and gracefully informs the user via django message framework.

![image](https://user-images.githubusercontent.com/5083532/212469501-691cf4d4-ccb4-4a88-9348-bf975680abd9.png)
![image](https://user-images.githubusercontent.com/5083532/212469552-e5194bd1-f92d-4e77-a206-686950011b11.png)
![image](https://user-images.githubusercontent.com/5083532/212470087-a948f910-7c18-4157-87dd-46a7d61f185c.png)


@abhi1693 sure! Put a PR in against the `develop` branch for now, although we may end up needing to defer this until the v3.5 release depending on how involved the implementation ends up being. Thanks!
There are also plugins (like ldap, okta saml) that write to the database when a user logs in
Is there a way to tackle that too?
I don't have a setup for that yet so I cannot say
I am also interested with this feature.
A primary netbox instance where read/write operations are possible on sensitive network, and some read only instances reachable from less sensitive networks in the company (or in remote offices with huge latency with central).

Django as a database router functionnality. Maybe it can help in order to separate R/W operations for frontend user experience, and read operations for models storage ?
Or maybe in my usecase I can simply declare users with no write privileges at all.

Commit message: fixed attribute error

Patch to review:
diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py
index f9faa9c5d87..f8745a1f7c2 100644
--- a/netbox/netbox/middleware.py
+++ b/netbox/netbox/middleware.py
@@ -3,10 +3,11 @@
 from urllib import parse
 
 from django.conf import settings
-from django.contrib import auth
+from django.contrib import auth, messages
 from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
 from django.core.exceptions import ImproperlyConfigured
-from django.db import ProgrammingError
+from django.db import connection, ProgrammingError
+from django.db.utils import InternalError
 from django.http import Http404, HttpResponseRedirect
 
 from extras.context_managers import change_logging
@@ -16,6 +17,7 @@
 
 __all__ = (
     'CoreMiddleware',
+    'MaintenanceModeMiddleware',
     'RemoteUserMiddleware',
 )
 
@@ -166,3 +168,43 @@ def _get_groups(self, request):
             groups = []
         logger.debug(f"Groups are {groups}")
         return groups
+
+
+class MaintenanceModeMiddleware:
+    """
+    Middleware that checks if the application is in maintenance mode
+    and restricts write-related operations to the database.
+    """
+
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        if getattr(settings, 'MAINTENANCE_MODE', False):
+            self._prevent_db_write_operations()
+
+        return self.get_response(request)
+
+    @staticmethod
+    def _prevent_db_write_operations():
+        """
+        Prevent any write-related database operations.
+        """
+        with connection.cursor() as cursor:
+            cursor.execute(
+                'SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;'
+            )
+
+    def process_exception(self, request, exception):
+        """
+        Prevent any write-related database operations if an exception is raised.
+        """
+        if isinstance(exception, InternalError):
+            error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \
+                            'operations. Please try again later.'
+
+            if is_api_request(request):
+                return rest_api_server_error(request, error=error_message)
+
+            messages.error(request, error_message)
+            return HttpResponseRedirect(request.path_info)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 206b00fe78a..07caa924af5 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -382,6 +382,7 @@ def _setting(name, default=None):
     'django.middleware.security.SecurityMiddleware',
     'netbox.middleware.RemoteUserMiddleware',
     'netbox.middleware.CoreMiddleware',
+    'netbox.middleware.MaintenanceModeMiddleware',
     'django_prometheus.middleware.PrometheusAfterMiddleware',
 ]
 


Code review comments:
diff_hunk: @@ -166,3 +168,43 @@ def _get_groups(self, request):
             groups = []
         logger.debug(f"Groups are {groups}")
         return groups
+
+
+class MaintenanceModeMiddleware:
+    """
+    Middleware that checks if the application is in maintenance mode
+    and restricts write-related operations to the database.
+    """
+
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        if getattr(settings, 'MAINTENANCE_MODE', False):
path: netbox/netbox/middleware.py
text: @user1:
We need to reference this setting from `get_config()` because it's a dynamic parameter, which can be set either in `configuration.py` or a config revision in the database.

```suggestion
        if get_config().MAINTENANCE_MODE:
```

@author:
~Test cases fail if I make that change. I'm not sure if there is another way of calling this parameter.~ Seems like a configuration error in my local setup. I'll commit and push the changes.