Skip to content

Commit 798e93a

Browse files
committed
Address review: permission check before validation, reject text=None
- Move LabelSet.objects.get + permission check ahead of all field validation so a non-owner can't distinguish 'reached validation' from 'denied by permission' via different error messages. This is defense-in-depth on top of the existing IDOR-safe deny pattern. - Tighten the text guard from 'is not None and not strip()' to 'not (text and text.strip())' so the model default ('Text Label') cannot apply when the client omits the argument entirely (GraphQL passes None for an unsupplied optional argument). - Normalise color='' to None before validate_color so callers who explicitly pass an empty color receive the model default rather than triggering a misleading validation error. - Add regression tests: * test_omitted_text_is_rejected pins the None-text path * test_permission_denied_message_does_not_leak_validation_state verifies validation-order indistinguishability for non-owners
1 parent daace55 commit 798e93a

2 files changed

Lines changed: 87 additions & 20 deletions

File tree

config/graphql/label_mutations.py

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -237,27 +237,13 @@ def mutate(
237237
obj = None
238238
obj_id = None
239239

240-
# Reject blank ``text`` early. ``text`` is GraphQL-optional so the
241-
# model default ("Text Label") would silently apply, which produces
242-
# surprising labels for clients that forgot to pass a value or
243-
# passed an empty string. Django's ``blank=False`` is form-only
244-
# and is NOT enforced by ``objects.create()``.
245-
if text is not None and not text.strip():
246-
return CreateLabelForLabelsetMutation(
247-
obj=None,
248-
obj_id=None,
249-
message="Label text cannot be blank.",
250-
ok=False,
251-
)
252-
253-
# Validate color format (defense in depth)
254-
is_valid_color, color_error = validate_color(color)
255-
if not is_valid_color:
256-
return CreateLabelForLabelsetMutation(
257-
obj=None, obj_id=None, message=color_error, ok=False
258-
)
259-
260240
try:
241+
# Permission check FIRST — runs before any field validation
242+
# so a non-owner can't distinguish "reached validation" from
243+
# "denied by permission" via different error messages
244+
# (defense-in-depth; consistent with the IDOR-safe deny
245+
# pattern documented in
246+
# ``docs/permissioning/consolidated_permissioning_guide.md``).
261247
labelset = LabelSet.objects.get(pk=from_global_id(labelset_id)[1])
262248
if not user_has_permission_for_obj(
263249
info.context.user,
@@ -267,6 +253,34 @@ def mutate(
267253
):
268254
# Generic deny path — same message and code path as not-found
269255
raise LabelSet.DoesNotExist()
256+
257+
# ``text`` is GraphQL-optional so without this guard the model
258+
# default ("Text Label") would silently apply, producing
259+
# surprising labels for clients that forgot to pass a value
260+
# or passed whitespace. Django's ``blank=False`` is
261+
# form-only and NOT enforced by ``objects.create()``. The
262+
# ``not (text and text.strip())`` form covers ``None``,
263+
# empty string, and whitespace-only in one expression.
264+
if not (text and text.strip()):
265+
return CreateLabelForLabelsetMutation(
266+
obj=None,
267+
obj_id=None,
268+
message="Label text is required and cannot be blank.",
269+
ok=False,
270+
)
271+
272+
# Validate color format (defense in depth). ``color=""`` is
273+
# normalised to ``None`` so the model default ("#FFFFFF")
274+
# applies, matching the silent-default behaviour clients
275+
# already get from omitting the argument.
276+
if color == "":
277+
color = None
278+
is_valid_color, color_error = validate_color(color)
279+
if not is_valid_color:
280+
return CreateLabelForLabelsetMutation(
281+
obj=None, obj_id=None, message=color_error, ok=False
282+
)
283+
270284
logger.debug("CreateLabelForLabelsetMutation - mutate / Labelset", labelset)
271285
# Drop None and empty-string values so model field defaults
272286
# apply (description, color, icon, text are NOT NULL with

opencontractserver/tests/test_label_mutations.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,3 +378,56 @@ def test_blank_text_is_rejected(self) -> None:
378378
self.assertFalse(data["ok"])
379379
self.assertIn("blank", data["message"].lower())
380380
self.assertEqual(self.labelset.annotation_labels.count(), 0)
381+
382+
def test_omitted_text_is_rejected(self) -> None:
383+
"""A client that omits ``text`` entirely must NOT receive a
384+
``"Text Label"`` row by default — the model default is hidden
385+
behind a required-field guard.
386+
"""
387+
# GraphQL doesn't support omitting an argument from variables,
388+
# but the resolver default is ``text=None``; build a custom
389+
# variables dict that simulates the omission by passing None
390+
# via JSON null (the GraphQL coercion that sends ``null`` for an
391+
# unsupplied optional argument).
392+
variables = self._create_variables("placeholder")
393+
variables["text"] = None
394+
395+
result = self.client.execute(
396+
CREATE_LABEL_FOR_LABELSET_MUTATION,
397+
variables=variables,
398+
)
399+
400+
self.assertIsNone(result.get("errors"))
401+
data = result["data"]["createAnnotationLabelForLabelset"]
402+
self.assertFalse(data["ok"])
403+
self.assertIn("blank", data["message"].lower())
404+
self.assertEqual(self.labelset.annotation_labels.count(), 0)
405+
self.assertFalse(
406+
AnnotationLabel.objects.filter(text="Text Label").exists()
407+
)
408+
409+
def test_permission_denied_message_does_not_leak_validation_state(
410+
self,
411+
) -> None:
412+
"""A non-owner caller hitting validation should still get the
413+
same generic 404-style denial as if the labelset didn't exist —
414+
the permission check runs FIRST so blank-text vs
415+
permission-denied are indistinguishable to outsiders.
416+
"""
417+
# ``other_user`` has no permission on ``self.labelset``. Send
418+
# an *invalid* mutation (blank text) — the response must look
419+
# like a "does not exist", NOT like "blank text".
420+
variables = self._create_variables(" ")
421+
422+
result = self.other_client.execute(
423+
CREATE_LABEL_FOR_LABELSET_MUTATION,
424+
variables=variables,
425+
)
426+
427+
self.assertIsNone(result.get("errors"))
428+
data = result["data"]["createAnnotationLabelForLabelset"]
429+
self.assertFalse(data["ok"])
430+
# Must NOT mention "blank" — that would leak that the caller's
431+
# request was structurally valid but rejected by permission.
432+
self.assertNotIn("blank", data["message"].lower())
433+
self.assertIn("does not exist", data["message"].lower())

0 commit comments

Comments
 (0)