Skip to content

Commit b9b1beb

Browse files
authored
Merge pull request #1373 from Open-Source-Legal/claude/fix-issue-1360-rT6dl
Fix DRFMutation/DRFDeletion IOSettings silent misconfiguration trap
2 parents 3b96d80 + 62381da commit b9b1beb

116 files changed

Lines changed: 185 additions & 41 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 7 additions & 0 deletions

config/graphql/base.py

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
import logging
33
import traceback
44
from abc import ABC
5-
from typing import Any
5+
from collections.abc import Sequence
6+
from typing import Any, ClassVar, Optional
67

78
import django.db.models
89
import graphene
@@ -22,6 +23,22 @@
2223
logger = logging.getLogger(__name__)
2324

2425

26+
def _require_io_setting(mutation_cls: type, name: str) -> Any:
27+
"""Raise ``NotImplementedError`` if ``cls.IOSettings.<name>`` is missing or ``None``."""
28+
io_settings = getattr(mutation_cls, "IOSettings", None)
29+
if io_settings is None:
30+
raise NotImplementedError(
31+
f"{mutation_cls.__name__} must define an IOSettings class."
32+
)
33+
value = getattr(io_settings, name, None)
34+
if value is None:
35+
raise NotImplementedError(
36+
f"{mutation_cls.__name__}.IOSettings.{name} must be set by the "
37+
f"subclass."
38+
)
39+
return value
40+
41+
2542
class OpenContractsNode(Node):
2643
class Meta:
2744
name = "Node"
@@ -75,7 +92,8 @@ def resolve_total_count(root, info, **kwargs) -> Any:
7592

7693
class DRFDeletion(graphene.Mutation):
7794
class IOSettings(ABC):
78-
lookup_field = "id"
95+
lookup_field: ClassVar[str] = "id"
96+
model: ClassVar[Optional[type[django.db.models.Model]]] = None
7997

8098
class Arguments:
8199
id = graphene.String(required=False)
@@ -88,12 +106,24 @@ class Arguments:
88106
@graphql_ratelimit(rate=RateLimits.WRITE_LIGHT)
89107
def mutate(cls, root, info, *args, **kwargs) -> "DRFDeletion":
90108

109+
# Unlike ``DRFMutation.mutate`` below, this method intentionally does
110+
# NOT wrap the body in ``except Exception``. Errors (including the
111+
# ``NotImplementedError`` from ``_require_io_setting`` and the
112+
# ``ValueError`` for missing lookup args) propagate to the GraphQL
113+
# framework as raw execution errors.
91114
ok = False
92115

93-
id = from_global_id(kwargs.get(cls.IOSettings.lookup_field, None))[1]
116+
model = _require_io_setting(cls, "model")
117+
lookup_field = cls.IOSettings.lookup_field
118+
lookup_value = kwargs.get(lookup_field)
119+
if lookup_value is None:
120+
raise ValueError(
121+
f"'{lookup_field}' is required to identify the object to delete."
122+
)
123+
id = from_global_id(lookup_value)[1]
94124
# Filter through visible_to_user() to prevent IDOR -- returns same
95125
# DoesNotExist error whether object is missing or user lacks access.
96-
obj = cls.IOSettings.model.objects.visible_to_user(info.context.user).get(pk=id)
126+
obj = model.objects.visible_to_user(info.context.user).get(pk=id)
97127

98128
# if there's a user lock, only the lock holder (or superuser) can proceed
99129
if hasattr(obj, "user_lock") and obj.user_lock is not None:
@@ -126,11 +156,13 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFDeletion":
126156

127157
class DRFMutation(graphene.Mutation):
128158
class IOSettings(ABC):
129-
pk_fields: list[str | int] = []
130-
lookup_field = "id"
131-
model: django.db.models.Model = None
132-
graphene_model: DjangoObjectType = None
133-
serializer = None
159+
# Frozen default — subclasses override with their own list/tuple.
160+
# Using a tuple here avoids the shared-mutable-default footgun.
161+
pk_fields: ClassVar[Sequence[str]] = ()
162+
lookup_field: ClassVar[str] = "id"
163+
model: ClassVar[Optional[type[django.db.models.Model]]] = None
164+
graphene_model: ClassVar[Optional[type[DjangoObjectType]]] = None
165+
serializer: ClassVar[Optional[type[serializers.Serializer]]] = None
134166

135167
class Arguments:
136168
pass
@@ -178,20 +210,25 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFMutation":
178210
raise ValueError("No user in this request...")
179211

180212
logger.info(f"DRFMutation - kwargs: {kwargs}")
181-
serializer = cls.IOSettings.serializer
213+
serializer = _require_io_setting(cls, "serializer")
214+
model = _require_io_setting(cls, "model")
215+
# ``graphene_model`` is the class itself; ``__name__`` is the GraphQL
216+
# type name (e.g. ``"CorpusType"``). ``__class__.__name__`` would
217+
# return the metaclass name (``"SubclassWithMeta_Meta"``) which is
218+
# the bug this PR fixes — kept dereferenced as ``__name__`` below.
219+
graphene_model = _require_io_setting(cls, "graphene_model")
182220

183221
if hasattr(cls.IOSettings, "pk_fields"):
184222
for pk_field in cls.IOSettings.pk_fields:
185223
if pk_field in kwargs:
186-
if isinstance(kwargs[pk_field], list):
187-
pk_value = []
188-
for global_id in kwargs[pk_field]:
189-
# global_id is already the ID string, not a key
190-
pk_value.append(from_global_id(global_id)[1])
224+
raw_value = kwargs[pk_field]
225+
if isinstance(raw_value, list):
226+
kwargs[pk_field] = [
227+
from_global_id(global_id)[1] for global_id in raw_value
228+
]
191229
else:
192-
logger.info(f"pk field is: {kwargs.get(pk_field, None)}")
193-
pk_value = from_global_id(kwargs.get(pk_field, None))[1]
194-
kwargs[pk_field] = pk_value
230+
logger.info(f"pk field is: {raw_value}")
231+
kwargs[pk_field] = from_global_id(raw_value)[1]
195232

196233
# Check if lookup_field exists in IOSettings and if it's in kwargs
197234
# This allows create mutations to work without requiring lookup_field
@@ -204,10 +241,8 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFMutation":
204241
logger.info("Lookup_field specified - update")
205242
# Filter through visible_to_user() to prevent IDOR --
206243
# returns same DoesNotExist whether missing or no access.
207-
obj = cls.IOSettings.model.objects.visible_to_user(
208-
info.context.user
209-
).get(
210-
pk=from_global_id(kwargs.get(cls.IOSettings.lookup_field, None))[1]
244+
obj = model.objects.visible_to_user(info.context.user).get(
245+
pk=from_global_id(kwargs[cls.IOSettings.lookup_field])[1]
211246
)
212247

213248
logger.info(f"Retrieved obj: {obj}")
@@ -243,9 +278,7 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFMutation":
243278
obj_serializer.save()
244279
ok = True
245280
message = "Success"
246-
obj_id = to_global_id(
247-
cls.IOSettings.graphene_model.__class__.__name__, obj.id
248-
)
281+
obj_id = to_global_id(graphene_model.__name__, obj.id)
249282
logger.info("Succeeded updating obj")
250283

251284
else:
@@ -265,9 +298,7 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFMutation":
265298

266299
ok = True
267300
message = "Success"
268-
obj_id = to_global_id(
269-
cls.IOSettings.graphene_model.__class__.__name__, obj.id
270-
)
301+
obj_id = to_global_id(graphene_model.__name__, obj.id)
271302

272303
except serializers.ValidationError as ve:
273304
logger.warning(f"Validation error in mutation: {ve.detail}")
-2.97 KB
-45 Bytes
1.96 KB
0 Bytes
217 Bytes
1.36 KB
-270 Bytes
713 Bytes

0 commit comments

Comments
 (0)