Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ gunzip -c defectdojo-v<VERSION>-backup.sql.gz | \
psql -h <db-endpoint> -U <master_user> -d defectdojo
```

3. Turn off read-only mode in the restored database. If you turned on read-only mode before the dump (see [Planning the cutover](#planning-the-cutover)), the restored copy is locked too. Clear the setting before you start DefectDojo Pro:

```bash
psql -h <db-endpoint> -U <master_user> -d defectdojo \
-c "UPDATE dojo_system_settings SET read_only_mode = false;"
```

If the restore reports errors, capture the output and contact support before you strip anything further out of the dump. Removing too much can leave the database in an inconsistent state that is harder to diagnose than the original error.

## Step 5: Restore your media files
Expand Down Expand Up @@ -209,7 +216,13 @@ Once the application is running against the restored database:

## Planning the cutover

The dump is a point-in-time snapshot, so anything created in the open source instance after you take it will not be in the Pro deployment. To avoid losing data, freeze the open source instance for the final dump and cutover, or run the migration during a quiet period.
The dump is a point-in-time snapshot, so anything created in the open source instance after you take it will not be in the Pro deployment. To avoid losing data, turn on read-only mode in the open source instance before the final dump or the final sync. Read-only mode is available in DefectDojo 3.4.200 and later.

1. Go to **Configuration > System Settings**, select **Read-only mode**, and click **Submit**.
2. Wait until **Configuration > Celery Status** shows no queued tasks. Then run `celery --app=dojo inspect active` in the Celery worker container, and wait until it reports `empty` for every worker. Tasks that started before the lock still write until they finish.
3. Take the final dump, or run the final sync. The dump keeps read-only mode turned on, so turn it off in the restored database (Step 4).

While read-only mode is on, DefectDojo rejects every change from the UI and the API, and pauses scheduled background jobs. Superusers can still sign in and change System Settings. To turn it off, clear **Read-only mode** and click **Submit**.

A dry run is worth the time. Migrate a recent copy first, validate it, and then repeat the process for the real cutover. The second run is faster, and it tells you how long the schema migration in Step 6 is going to take.

Expand Down
5 changes: 5 additions & 0 deletions dojo/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ def __call__(self, *args, **kwargs):
"""
from dojo.caching import reset_l1_cache # noqa: PLC0415
from dojo.request_cache import begin_task_cache, end_task_cache # noqa: PLC0415
from dojo.utils import get_system_setting # noqa: PLC0415 circular import
reset_l1_cache()
beat_tasks = {entry["task"] for entry in settings.CELERY_BEAT_SCHEDULE.values()}
if self.name in beat_tasks and get_system_setting("read_only_mode"):
logger.info("Read-only mode is on: skipped scheduled task %s", self.name)
return None
# Install a fresh task-scoped request-cache (begin) and drop it afterwards
# (end), so cache_for_request_or_task can memoize within this task without a
# value leaking to the next task on this reused worker thread.
Expand Down
4 changes: 4 additions & 0 deletions dojo/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from dojo.announcement.os_message import OS_MESSAGE_DISMISSED_KEY, get_os_banner
from dojo.labels import get_labels
from dojo.location.feature import locations_enabled
from dojo.middleware import READ_ONLY_MODE_MESSAGE
from dojo.models import System_Settings, UserAnnouncement


Expand All @@ -27,6 +28,9 @@ def globalize_vars(request):

additional_banners = []

if System_Settings.objects.get().read_only_mode:
additional_banners.append({"source": "read_only_mode", "message": str(READ_ONLY_MODE_MESSAGE), "style": "warning"})

if (os_banner := get_os_banner()) is not None:
token = os_banner.get("dismiss_token", "")
user = getattr(request, "user", None)
Expand Down
18 changes: 18 additions & 0 deletions dojo/db_migrations/0301_system_settings_read_only_mode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.17 on 2026-10-16 23:05

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('dojo', '0300_finding_upper_title_index'),
]

operations = [
migrations.AddField(
model_name='system_settings',
name='read_only_mode',
field=models.BooleanField(default=False, help_text='Rejects all changes from the UI and the API, and pauses scheduled background jobs. Superusers can still change System Settings, so they can turn this off. Turn it on to freeze this instance before a migration.', verbose_name='Read-only mode'),
),
]
43 changes: 42 additions & 1 deletion dojo/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
import re
import time
from contextlib import contextmanager, suppress
from http import HTTPStatus
from threading import local
from urllib.parse import quote

import pghistory.middleware
from django.conf import settings
from django.contrib import messages
from django.db import models
from django.dispatch import receiver
from django.http import HttpResponseRedirect
from django.http import HttpResponseRedirect, JsonResponse
from django.urls import reverse
from django.utils import translation
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy
from rest_framework.permissions import SAFE_METHODS
from watson.middleware import SearchContextMiddleware
from watson.search import search_context_manager

Expand Down Expand Up @@ -120,6 +125,42 @@ def __call__(self, request):
return response


READ_ONLY_MODE_MESSAGE = gettext_lazy(
"Read-only mode is on. DefectDojo does not accept changes until a superuser turns it off in System Settings.",
)


class ReadOnlyModeMiddleware:

"""Rejects every unsafe request while System_Settings.read_only_mode is on, except READ_ONLY_MODE_EXEMPT_URLS."""

def __init__(self, get_response):
self.get_response = get_response
self.exempt_urls = [re.compile(pattern) for pattern in settings.READ_ONLY_MODE_EXEMPT_URLS]

def __call__(self, request):
if request.method in SAFE_METHODS:
return self.get_response(request)
path = request.path_info.lstrip("/")
if any(pattern.match(path) for pattern in self.exempt_urls):
return self.get_response(request)
if not get_cached_system_settings().read_only_mode:
return self.get_response(request)

if (
path.startswith(f"{settings.URL_PREFIX}api/v")
or request.headers.get("HX-Request") == "true"
or request.headers.get("X-Requested-With") == "XMLHttpRequest"
):
return JsonResponse({"detail": READ_ONLY_MODE_MESSAGE}, status=HTTPStatus.LOCKED)

messages.error(request, READ_ONLY_MODE_MESSAGE)
referer = request.headers.get("Referer", "")
if not url_has_allowed_host_and_scheme(referer, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
referer = reverse("home")
return HttpResponseRedirect(referer)


class DojoSettingsManagerMiddleware:
# Caching of the System_Settings singleton lives in dojo.caching (L1+L2). This
# middleware only (a) resets the request-scoped L1 tier and (b) surfaces a
Expand Down
17 changes: 17 additions & 0 deletions dojo/settings/settings.dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,22 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
rf"^{URL_PREFIX}reset/",
)

# Unsafe requests that still pass while read-only mode is on: sign-in and System Settings,
# so a superuser can always turn the mode off again.
READ_ONLY_MODE_EXEMPT_URLS = (
rf"^{URL_PREFIX}login$",
rf"^{URL_PREFIX}logout$",
r"complete/",
r"oauth2/idpresponse",
rf"^{URL_PREFIX}password_reset/",
rf"^{URL_PREFIX}reset/",
rf"^{URL_PREFIX}forgot_username",
rf"^{URL_PREFIX}change_password$",
rf"^{URL_PREFIX}api/v2/api-token-auth/",
rf"^{URL_PREFIX}system_settings$",
rf"^{URL_PREFIX}api/v2/system_settings/",
)

AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "dojo.user.validators.DojoCommonPasswordValidator",
Expand Down Expand Up @@ -949,6 +965,7 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
"django_htmx.middleware.HtmxMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"dojo.middleware.LoginRequiredMiddleware",
"dojo.middleware.ReadOnlyModeMiddleware",
"dojo.middleware.AdditionalHeaderMiddleware",
"crum.CurrentRequestUserMiddleware",
"dojo.authorization.middleware.AuthorizationMiddleware",
Expand Down
9 changes: 9 additions & 0 deletions dojo/system_settings/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,15 @@ class System_Settings(models.Model):
"When turned on, all filter operations in the UI will require string matches rather than ID. "
"This is a performance enhancement to avoid fetching objects unnecessarily.",
))
read_only_mode = models.BooleanField(
default=False,
blank=False,
verbose_name=_("Read-only mode"),
help_text=_(
"Rejects all changes from the UI and the API, and pauses scheduled background jobs. "
"Superusers can still change System Settings, so they can turn this off. "
"Turn it on to freeze this instance before a migration.",
))

from dojo.middleware import System_Settings_Manager # noqa: PLC0415 circular import
objects = System_Settings_Manager()
Expand Down
Loading
Loading