Skip to content

Commit f344253

Browse files
feat(cache): guard against saving cache-derived System_Settings snapshots
System_Settings.objects.get() returns a read-only snapshot rebuilt from the in-process (L1) cache; saving it could overwrite concurrent changes with stale field values. Tag such instances and make System_Settings.save() fail loudly instead of silently persisting a stale snapshot. - dojo/caching.py: add READ_ONLY_CACHE_MARKER, ReadOnlyCachedInstanceError, and a read_only= param on cache_dict_to_model (default off, so tuner/features keep saving their rebuilt instances for their read-modify-write flows). - System_Settings.save() raises ReadOnlyCachedInstanceError when the instance is tagged; get_cached_system_settings() tags the snapshot read_only=True. - Migrate get()->modify->save() writer sites (incl. the system_settings() test helper) to objects.get(no_cache=True) for a fresh, saveable row. - Document the singleton manager .get() semantics (ignores filter kwargs, never raises DoesNotExist; data migrations use apps.get_model, so get_or_create is unaffected). Fix stale L1/L2 comments left by the drop-L2 change. - test_caching: cover the save-guard and the read_only flag.
1 parent cd6d332 commit f344253

14 files changed

Lines changed: 112 additions & 28 deletions

dojo/caching.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,33 @@ def model_to_cache_dict(instance) -> dict:
150150
return {f.attname: f.value_from_object(instance) for f in instance._meta.concrete_fields}
151151

152152

153-
def cache_dict_to_model(model_cls, data: dict):
153+
# Attribute set on instances rebuilt for read-only cache use (``read_only=True``).
154+
# A guarded model's ``save()`` checks it and refuses to persist a cache-derived
155+
# snapshot (see ``System_Settings.save``). Instance-level, so it never leaks to a
156+
# freshly-fetched (``no_cache=True``) instance, which is a distinct object.
157+
READ_ONLY_CACHE_MARKER = "_dd_read_only_cache_snapshot"
158+
159+
160+
class ReadOnlyCachedInstanceError(RuntimeError):
161+
162+
"""
163+
Raised when code tries to save a model instance rebuilt from the read-through
164+
cache. Such an instance is a point-in-time snapshot; persisting it can clobber
165+
concurrent changes with stale field values. Fetch a fresh DB instance
166+
(e.g. ``System_Settings.objects.get(no_cache=True)``) before saving.
167+
"""
168+
169+
170+
def cache_dict_to_model(model_cls, data: dict, *, read_only: bool = False):
154171
"""
155172
Rebuild an in-memory model instance from a ``model_to_cache_dict`` dict.
156173
157174
For read-only use; callers that persist changes must fetch a fresh DB instance
158-
rather than saving a cache-derived one.
175+
rather than saving a cache-derived one. Pass ``read_only=True`` to tag the
176+
instance with ``READ_ONLY_CACHE_MARKER`` so a guarded model's ``save()`` fails
177+
loudly instead of silently writing a stale snapshot.
159178
"""
160-
return model_cls(**data)
179+
instance = model_cls(**data)
180+
if read_only:
181+
setattr(instance, READ_ONLY_CACHE_MARKER, True)
182+
return instance

dojo/celery.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ def __call__(self, *args, **kwargs):
3131
Also resets the request/task-scoped L1 settings cache at the start of every
3232
task (including eager): a prefork worker reuses its thread across tasks, so an
3333
L1 value cached during a prior task (e.g. System_Settings) would otherwise be
34-
served stale even after another process changed and saved it. The shared L2
35-
tier is left intact, so a reset task re-reads each singleton once from L2.
34+
served stale even after another process changed and saved it. There is no
35+
shared tier, so a reset task re-reads each singleton once from the DB.
3636
3737
The apply_async method injects ``async_user_id`` into kwargs when a task
3838
is dispatched. Here we pop it, resolve to a user instance, and set it

dojo/middleware.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@
2626

2727
logger = logging.getLogger(__name__)
2828

29-
# Two-tier (in-process L1 + django.core.cache L2) read-through cache for the
30-
# System_Settings singleton, via the shared dojo_settings_cache decorator. Stored
31-
# as a plain dict (no pickled model graph) and rebuilt into a read-only instance
32-
# per call. Write paths use ``objects.get(no_cache=True)`` and are unaffected.
29+
# In-process (L1) read-through cache for the System_Settings singleton, via the
30+
# shared dojo_settings_cache decorator. Stored as a plain dict (no pickled model
31+
# graph) and rebuilt into a read-only instance per call. Write paths use
32+
# ``objects.get(no_cache=True)`` and are unaffected.
3333
SYSTEM_SETTINGS_CACHE_KEY = "dojo.system_settings.singleton"
3434

3535

@@ -50,7 +50,9 @@ def get_cached_system_settings():
5050
data = _cached_system_settings_dict()
5151
if not isinstance(data, dict):
5252
return System_Settings()
53-
return cache_dict_to_model(System_Settings, data)
53+
# read_only=True tags the rebuilt snapshot so System_Settings.save() refuses
54+
# to persist it; writers must use objects.get(no_cache=True) for a fresh row.
55+
return cache_dict_to_model(System_Settings, data, read_only=True)
5456

5557

5658
@receiver(models.signals.post_save, sender="dojo.System_Settings")
@@ -165,10 +167,17 @@ def get_from_db(self, *args, **kwargs):
165167
return from_db
166168

167169
def get(self, no_cache=False, *args, **kwargs): # noqa: FBT002 - this is bit hard to fix nice have this universally fixed
170+
# NOTE: this override does not behave like Manager.get(). System_Settings is
171+
# a singleton, so the cached path ignores any filter args/kwargs, always
172+
# returns the one row, and never raises DoesNotExist/MultipleObjectsReturned
173+
# (get_or_create against this manager therefore won't create — data
174+
# migrations use apps.get_model(), which keeps Django's default manager).
175+
# The cached instance is a read-only snapshot (see get_cached_system_settings);
176+
# to modify-and-save, fetch a fresh row with no_cache=True.
168177
if no_cache:
169178
# logger.debug('no_cache specified, loading system settings from db')
170179
return self.get_from_db(*args, **kwargs)
171-
# Read through the shared L1/L2 cache (dojo.caching). L1 is request/task
180+
# Read through the in-process (L1) cache (dojo.caching). L1 is request/task
172181
# scoped (reset by the middleware and the Celery task base), so repeated
173182
# reads within a request are served in-process.
174183
return get_cached_system_settings()

dojo/system_settings/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,22 @@ class System_Settings(models.Model):
351351
from dojo.middleware import System_Settings_Manager # noqa: PLC0415 circular import
352352
objects = System_Settings_Manager()
353353

354+
def save(self, *args, **kwargs):
355+
# Guard against persisting an instance handed back by the read-through
356+
# cache. ``System_Settings.objects.get()`` returns a snapshot rebuilt from
357+
# the cached dict (see dojo.middleware.get_cached_system_settings); saving
358+
# it could overwrite concurrent changes with stale values. Writers must
359+
# fetch a fresh instance with ``System_Settings.objects.get(no_cache=True)``.
360+
from dojo.caching import READ_ONLY_CACHE_MARKER, ReadOnlyCachedInstanceError # noqa: PLC0415 circular import
361+
if getattr(self, READ_ONLY_CACHE_MARKER, False):
362+
msg = (
363+
"Refusing to save a System_Settings instance obtained from the read-through cache "
364+
"(System_Settings.objects.get()); it is a read-only snapshot. Fetch a fresh instance "
365+
"with System_Settings.objects.get(no_cache=True) before saving."
366+
)
367+
raise ReadOnlyCachedInstanceError(msg)
368+
super().save(*args, **kwargs)
369+
354370
def clean(self):
355371
super().clean()
356372

unittests/dojo_test_case.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def get_test_admin(self, *args, **kwargs):
138138
return User.objects.get(username="admin")
139139

140140
def system_settings(self, **kwargs):
141-
ss = System_Settings.objects.get()
141+
ss = System_Settings.objects.get(no_cache=True)
142142
for key, value in kwargs.items():
143143
setattr(ss, key, value)
144144
ss.save()

unittests/test_caching.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from dojo.caching import (
44
_L1_STORE, # noqa: PLC2701 test needs the in-process store
5+
READ_ONLY_CACHE_MARKER,
6+
ReadOnlyCachedInstanceError,
57
cache_dict_to_model,
68
dojo_settings_cache,
79
invalidate_dojo_settings_cache,
@@ -83,3 +85,38 @@ def test_round_trip_preserves_field_values(self):
8385
rebuilt = cache_dict_to_model(System_Settings, data)
8486
self.assertEqual(rebuilt.pk, settings_obj.pk)
8587
self.assertEqual(rebuilt.enable_deduplication, settings_obj.enable_deduplication)
88+
89+
def test_read_only_flag_only_when_requested(self):
90+
data = model_to_cache_dict(System_Settings.objects.get(no_cache=True))
91+
# default: saveable (no marker)
92+
self.assertFalse(getattr(cache_dict_to_model(System_Settings, data), READ_ONLY_CACHE_MARKER, False))
93+
# read_only=True: tagged as a read-only snapshot
94+
self.assertTrue(getattr(cache_dict_to_model(System_Settings, data, read_only=True), READ_ONLY_CACHE_MARKER, False))
95+
96+
97+
class SystemSettingsSaveGuardTest(DojoTestCase):
98+
99+
"""
100+
The read-through cache hands back a read-only snapshot: saving the instance
101+
returned by ``System_Settings.objects.get()`` must fail loudly, while a fresh
102+
``no_cache=True`` instance saves normally.
103+
"""
104+
105+
def setUp(self):
106+
_L1_STORE.clear()
107+
108+
def tearDown(self):
109+
_L1_STORE.clear()
110+
111+
def test_saving_cached_instance_raises(self):
112+
cached = System_Settings.objects.get() # read-through (cached) path
113+
self.assertTrue(getattr(cached, READ_ONLY_CACHE_MARKER, False))
114+
cached.enable_deduplication = not cached.enable_deduplication
115+
with self.assertRaises(ReadOnlyCachedInstanceError):
116+
cached.save()
117+
118+
def test_no_cache_instance_is_saveable(self):
119+
fresh = System_Settings.objects.get(no_cache=True)
120+
self.assertFalse(getattr(fresh, READ_ONLY_CACHE_MARKER, False))
121+
fresh.enable_deduplication = not fresh.enable_deduplication
122+
fresh.save() # must not raise

unittests/test_deduplication_logic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1894,7 +1894,7 @@ def create_new_test_and_engagment_from_finding(self, finding):
18941894
return test_new, eng_new
18951895

18961896
def enable_dedupe(self, *, enable=True):
1897-
system_settings = System_Settings.objects.get()
1897+
system_settings = System_Settings.objects.get(no_cache=True)
18981898
system_settings.enable_deduplication = enable
18991899
system_settings.save()
19001900

unittests/test_duplication_loops.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,7 +378,7 @@ def test_list_relations_for_three_reverse(self):
378378
# Test that Delete Duplicate Findings & Maximum Duplicate is correctly deleting olding finding first based off of finding date value
379379
def test_delete_duplicate_order(self):
380380
# Turn on delete duplicates and set the maximum dedupe value to 1
381-
system_settings = System_Settings.objects.get()
381+
system_settings = System_Settings.objects.get(no_cache=True)
382382
system_settings.delete_duplicates = True
383383
system_settings.max_dupes = 1
384384
system_settings.save()
@@ -409,7 +409,7 @@ def test_delete_duplicate_order(self):
409409

410410
def test_delete_duplicate_order_same_date_tiebreak_by_id(self):
411411
"""When duplicate findings share the same date, excess deletes use id as tie-break (oldest id first)."""
412-
system_settings = System_Settings.objects.get()
412+
system_settings = System_Settings.objects.get(no_cache=True)
413413
system_settings.delete_duplicates = True
414414
system_settings.max_dupes = 1
415415
system_settings.save()

unittests/test_false_positive_history_logic.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2031,26 +2031,26 @@ def create_new_test_and_engagment_and_product_from_finding(self, finding):
20312031
return test_new, eng_new, product_new
20322032

20332033
def enable_false_positive_history(self):
2034-
system_settings = System_Settings.objects.get()
2034+
system_settings = System_Settings.objects.get(no_cache=True)
20352035
system_settings.false_positive_history = True
20362036
system_settings.save()
20372037

20382038
def enable_retroactive_false_positive_history(self):
2039-
system_settings = System_Settings.objects.get()
2039+
system_settings = System_Settings.objects.get(no_cache=True)
20402040
system_settings.retroactive_false_positive_history = True
20412041
system_settings.save()
20422042

20432043
def disable_retroactive_false_positive_history(self):
2044-
system_settings = System_Settings.objects.get()
2044+
system_settings = System_Settings.objects.get(no_cache=True)
20452045
system_settings.retroactive_false_positive_history = False
20462046
system_settings.save()
20472047

20482048
def enable_dedupe(self):
2049-
system_settings = System_Settings.objects.get()
2049+
system_settings = System_Settings.objects.get(no_cache=True)
20502050
system_settings.enable_deduplication = True
20512051
system_settings.save()
20522052

20532053
def disable_dedupe(self):
2054-
system_settings = System_Settings.objects.get()
2054+
system_settings = System_Settings.objects.get(no_cache=True)
20552055
system_settings.enable_deduplication = False
20562056
system_settings.save()

unittests/test_notifications.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,7 @@ def test_system_webhook_timeout(self):
941941
self.sys_wh.url = f"{self.url_base}/delay/3"
942942
self.sys_wh.save()
943943

944-
system_settings = System_Settings.objects.get()
944+
system_settings = System_Settings.objects.get(no_cache=True)
945945
system_settings.webhooks_notifications_timeout = 1
946946
system_settings.save()
947947

0 commit comments

Comments
 (0)