Skip to content

Commit e42bb27

Browse files
committed
Fix DRFMutation/DRFDeletion IOSettings silent misconfiguration trap
Closes #1360. `DRFMutation.IOSettings` declared `model: django.db.models.Model = None`, `graphene_model: DjangoObjectType = None` and `serializer = None` - the annotations typed instances, not classes, and there was no runtime guard on the `None` defaults. A subclass that forgot to override any of these crashed deep inside `mutate()` with `AttributeError` / `TypeError` that the broad `except Exception` masks as a generic "internal error". `DRFDeletion.IOSettings` had the inverse bug: `model` was never declared, even though `DRFDeletion.mutate` dereferences `cls.IOSettings.model.objects`. - Retype both `IOSettings` classes with `ClassVar[Optional[type[...]]] = None`, narrow `pk_fields` to `list[str]` (only used as a dict key). - Add `_require_io_setting(cls, name)` helper that raises `NotImplementedError("<Subclass>.IOSettings.<name> must be set by the subclass.")` on missing / None attributes. `DRFMutation.mutate` checks `model` / `serializer` / `graphene_model`; `DRFDeletion.mutate` checks `model` and also raises a descriptive `ValueError` when the `required=False` `id` argument is absent. - Graduate `config.graphql.base` out of the mypy baseline: remove the `[mypy-config.graphql.base]` section from `mypy.ini` and prune the ten corresponding lines from `docs/typing/mypy_baseline.txt`. - Regression tests in `opencontractserver/tests/test_security_hardening.py::TestIOSettingsRequiredFieldsGuard`.
1 parent d9709c4 commit e42bb27

5 files changed

Lines changed: 128 additions & 40 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3434

3535
### Fixed
3636

37+
- **DRFMutation/DRFDeletion `IOSettings` misconfiguration surfaces as a generic internal error** (Issue #1360, `config/graphql/base.py`): `DRFMutation.IOSettings` declared `model: django.db.models.Model = None`, `graphene_model: DjangoObjectType = None` and `serializer = None` — the type annotations were wrong (instance types, not class types, holding `None`), and there was no runtime guard. A subclass that forgot to override one of those would fail deep inside `mutate()` with an `AttributeError` (`'NoneType' object has no attribute 'objects'`) or `TypeError` (`'NoneType' object is not callable`), which the broad `except Exception` then masks as a generic "internal error" message back to the API caller. `DRFDeletion.IOSettings` had the inverse problem — it didn't declare `model` at all, even though `DRFDeletion.mutate` dereferences `cls.IOSettings.model.objects`. Discovered while working on #1332 (typing): mypy flagged four errors on `base.py:128/204/238/252` plus `base.py:93 "type[IOSettings]" has no attribute "model"`.
38+
- Retyped both `IOSettings` declarations with `ClassVar[Optional[type[...]]] = None` so the annotation matches reality (holding a class, not an instance, and nullable by default). `pk_fields` narrowed from `list[str | int]` to `list[str]` — the only call sites use it as a dict key.
39+
- Added `_require_io_setting(mutation_cls, name)` helper that fails fast with `NotImplementedError("<Subclass>.IOSettings.<name> must be set by the subclass.")` when a required attribute is `None` or missing. Called for `model`/`serializer`/`graphene_model` in `DRFMutation.mutate` and for `model` in `DRFDeletion.mutate`. The error is still caught by the broad `except Exception` and surfaced as "internal error" to the API, but the full descriptive message lands in the server log via `logger.error(traceback.format_exc())` — developer gets an obvious misconfiguration signal instead of an opaque `AttributeError`.
40+
- Tightened `DRFDeletion.mutate` to raise `ValueError` when `kwargs[lookup_field]` is missing (the `id` GraphQL argument is `required=False`), instead of passing `None` into `from_global_id` and getting an opaque `AttributeError`.
41+
- Graduated `config.graphql.base` out of the mypy baseline: removed the `[mypy-config.graphql.base]` section from `mypy.ini` and pruned the ten corresponding lines from `docs/typing/mypy_baseline.txt`. `mypy --config-file mypy.ini` is now clean on this module.
42+
- Regression tests in `opencontractserver/tests/test_security_hardening.py::TestIOSettingsRequiredFieldsGuard`: the helper raises `NotImplementedError` for each of `model`/`serializer`/`graphene_model` when missing or `None`, returns the configured value when present, and the base classes expose the `None` defaults the guard relies on.
43+
3744
- **Frontend coverage badge stuck on "unknown"** (`README.md:12`, `.github/workflows/codecov-notify.yml`, `.github/workflows/frontend.yml`, `.github/workflows/frontend-e2e.yml`, `frontend/package.json`, `frontend/yarn.lock`): PR #1322 pointed the README badge at a new merged `frontend` flag fed by a cross-workflow `lcov-result-merger@5` step inside `codecov-notify.yml`. Every `frontend-merged-coverage` upload since has landed at Codecov with `state: error` / `totals: null`, so the badge rendered "unknown" even though `frontend-unit` (31%), `frontend-component` (61%), and `frontend-e2e` (24%) were all processing correctly. Two defects in the merged lcov confirmed by local repro of the CI merge step: (1) `lcov-result-merger@5` emits a stripped lcov containing only `SF:`, `DA:`, `BRDA:`, `end_of_record` — it drops `TN:`, `FN`, `FNDA`, `FNF`, `FNH`, `LF`, `LH`, `BRF`, `BRH`, so line-summary fields required by Codecov's parser are absent; (2) Vitest v8 emits `src/...` (relative to `frontend/`) while `vite-plugin-istanbul` + `nyc report` emit `/home/runner/work/OpenContracts/OpenContracts/frontend/src/...` (absolute), and the merger keys on the literal path string so the same file appears as two records with conflicting hit counts. `codecov-notify.yml` also ran without `actions/checkout`, which Codecov's action docs explicitly recommend against. Fix: stop merging client-side and let Codecov aggregate server-side, since that is what flags are for. Each per-suite upload now declares two flags — `frontend-unit,frontend`, `frontend-component,frontend`, `frontend-e2e,frontend` — so the `frontend` flag total is the union of the three uploads computed by Codecov. `codecov-notify.yml` is reduced to its original gate-and-notify role (no artifact downloads, no `lcov-result-merger`, no merged upload). Deleted the `frontend-{unit,ct,e2e}-lcov` artifact publishes in `frontend.yml` / `frontend-e2e.yml`, removed the `lcov-result-merger@^5.0.1` devDep and the `coverage:merge` script from `frontend/package.json`, and pruned the orphaned entries from `frontend/yarn.lock`. README badge URL unchanged (`flag=frontend`).
3845

3946
### Changed

config/graphql/base.py

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import traceback
44
from abc import ABC
5+
from typing import Any, ClassVar, Optional
56

67
import django.db.models
78
import graphene
@@ -21,6 +22,30 @@
2122
logger = logging.getLogger(__name__)
2223

2324

25+
def _require_io_setting(mutation_cls: type, name: str) -> Any:
26+
"""Return ``cls.IOSettings.<name>``; raise if it is missing or ``None``.
27+
28+
``IOSettings`` is config-by-subclassing — concrete ``DRFMutation`` /
29+
``DRFDeletion`` subclasses are expected to override ``model``,
30+
``serializer`` and ``graphene_model``. Without this guard, a subclass
31+
that forgets one of those surfaces as a late ``AttributeError`` /
32+
``TypeError`` inside ``mutate()``, which gets swallowed by the broad
33+
``except Exception`` and reported to the user as a generic
34+
"internal error". Failing fast with a clear message here makes the
35+
misconfiguration obvious at mutation-invocation time.
36+
"""
37+
io_settings = getattr(mutation_cls, "IOSettings", None)
38+
value = (
39+
getattr(io_settings, name, None) if io_settings is not None else None
40+
)
41+
if value is None:
42+
raise NotImplementedError(
43+
f"{mutation_cls.__name__}.IOSettings.{name} must be set by the "
44+
f"subclass."
45+
)
46+
return value
47+
48+
2449
class OpenContractsNode(Node):
2550
class Meta:
2651
name = "Node"
@@ -72,7 +97,10 @@ def resolve_total_count(root, info, **kwargs):
7297

7398
class DRFDeletion(graphene.Mutation):
7499
class IOSettings(ABC):
75-
lookup_field = "id"
100+
lookup_field: ClassVar[str] = "id"
101+
# Concrete subclasses must override ``model`` with their Django model;
102+
# see ``_require_io_setting`` for the runtime guard.
103+
model: ClassVar[Optional[type[django.db.models.Model]]] = None
76104

77105
class Arguments:
78106
id = graphene.String(required=False)
@@ -87,10 +115,17 @@ def mutate(cls, root, info, *args, **kwargs):
87115

88116
ok = False
89117

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

95130
# if there's a user lock, only the lock holder (or superuser) can proceed
96131
if hasattr(obj, "user_lock") and obj.user_lock is not None:
@@ -123,11 +158,13 @@ def mutate(cls, root, info, *args, **kwargs):
123158

124159
class DRFMutation(graphene.Mutation):
125160
class IOSettings(ABC):
126-
pk_fields: list[str | int] = []
127-
lookup_field = "id"
128-
model: django.db.models.Model = None
129-
graphene_model: DjangoObjectType = None
130-
serializer = None
161+
pk_fields: ClassVar[list[str]] = []
162+
lookup_field: ClassVar[str] = "id"
163+
# Concrete subclasses must override ``model``, ``graphene_model`` and
164+
# ``serializer``; see ``_require_io_setting`` for the runtime guard.
165+
model: ClassVar[Optional[type[django.db.models.Model]]] = None
166+
graphene_model: ClassVar[Optional[type[DjangoObjectType]]] = None
167+
serializer: ClassVar[Optional[type[serializers.Serializer]]] = None
131168

132169
class Arguments:
133170
pass
@@ -175,20 +212,22 @@ def mutate(cls, root, info, *args, **kwargs):
175212
raise ValueError("No user in this request...")
176213

177214
logger.info(f"DRFMutation - kwargs: {kwargs}")
178-
serializer = cls.IOSettings.serializer
215+
serializer = _require_io_setting(cls, "serializer")
216+
model = _require_io_setting(cls, "model")
217+
graphene_model = _require_io_setting(cls, "graphene_model")
179218

180219
if hasattr(cls.IOSettings, "pk_fields"):
181220
for pk_field in cls.IOSettings.pk_fields:
182221
if pk_field in kwargs:
183-
if isinstance(kwargs[pk_field], list):
184-
pk_value = []
185-
for global_id in kwargs[pk_field]:
186-
# global_id is already the ID string, not a key
187-
pk_value.append(from_global_id(global_id)[1])
222+
raw_value = kwargs[pk_field]
223+
if isinstance(raw_value, list):
224+
kwargs[pk_field] = [
225+
from_global_id(global_id)[1]
226+
for global_id in raw_value
227+
]
188228
else:
189-
logger.info(f"pk field is: {kwargs.get(pk_field, None)}")
190-
pk_value = from_global_id(kwargs.get(pk_field, None))[1]
191-
kwargs[pk_field] = pk_value
229+
logger.info(f"pk field is: {raw_value}")
230+
kwargs[pk_field] = from_global_id(raw_value)[1]
192231

193232
# Check if lookup_field exists in IOSettings and if it's in kwargs
194233
# This allows create mutations to work without requiring lookup_field
@@ -201,10 +240,8 @@ def mutate(cls, root, info, *args, **kwargs):
201240
logger.info("Lookup_field specified - update")
202241
# Filter through visible_to_user() to prevent IDOR --
203242
# returns same DoesNotExist whether missing or no access.
204-
obj = cls.IOSettings.model.objects.visible_to_user(
205-
info.context.user
206-
).get(
207-
pk=from_global_id(kwargs.get(cls.IOSettings.lookup_field, None))[1]
243+
obj = model.objects.visible_to_user(info.context.user).get(
244+
pk=from_global_id(kwargs[cls.IOSettings.lookup_field])[1]
208245
)
209246

210247
logger.info(f"Retrieved obj: {obj}")
@@ -240,9 +277,7 @@ def mutate(cls, root, info, *args, **kwargs):
240277
obj_serializer.save()
241278
ok = True
242279
message = "Success"
243-
obj_id = to_global_id(
244-
cls.IOSettings.graphene_model.__class__.__name__, obj.id
245-
)
280+
obj_id = to_global_id(graphene_model.__class__.__name__, obj.id)
246281
logger.info("Succeeded updating obj")
247282

248283
else:
@@ -262,9 +297,7 @@ def mutate(cls, root, info, *args, **kwargs):
262297

263298
ok = True
264299
message = "Success"
265-
obj_id = to_global_id(
266-
cls.IOSettings.graphene_model.__class__.__name__, obj.id
267-
)
300+
obj_id = to_global_id(graphene_model.__class__.__name__, obj.id)
268301

269302
except serializers.ValidationError as ve:
270303
logger.warning(f"Validation error in mutation: {ve.detail}")

docs/typing/mypy_baseline.txt

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,6 @@ config/graphql/annotation_queries.py:641: error: Argument 1 to "from_global_id"
3131
config/graphql/annotation_queries.py:659: error: Argument 1 to "from_global_id" has incompatible type "Any | None"; expected "str" [arg-type]
3232
config/graphql/annotation_queries.py:719: error: Argument 1 to "from_global_id" has incompatible type "Any | None"; expected "str" [arg-type]
3333
config/graphql/badge_mutations.py:154: error: Argument 2 to "set_permissions_for_obj_to_user" has incompatible type "Badge"; expected "type[Model]" [arg-type]
34-
config/graphql/base.py:128: error: Incompatible types in assignment (expression has type "None", variable has type "Model") [assignment]
35-
config/graphql/base.py:190: error: Argument 1 to "from_global_id" has incompatible type "Any | None"; expected "str" [arg-type]
36-
config/graphql/base.py:190: error: Incompatible types in assignment (expression has type "str", variable has type "list[str]") [assignment]
37-
config/graphql/base.py:204: error: "Model" has no attribute "objects" [attr-defined]
38-
config/graphql/base.py:207: error: Argument 1 to "from_global_id" has incompatible type "Any | None"; expected "str" [arg-type]
39-
config/graphql/base.py:238: error: "None" not callable [misc]
40-
config/graphql/base.py:252: error: "None" not callable [misc]
41-
config/graphql/base.py:90: error: Argument 1 to "from_global_id" has incompatible type "Any | None"; expected "str" [arg-type]
42-
config/graphql/base.py:93: error: "type[IOSettings]" has no attribute "model" [attr-defined]
4334
config/graphql/base_types.py:28: error: Need type annotation for "id_to_children" (hint: "id_to_children: dict[<type>, <type>] = ...") [var-annotated]
4435
config/graphql/conversation_mutations.py:148: error: Argument 2 to "set_permissions_for_obj_to_user" has incompatible type "Conversation"; expected "type[Model]" [arg-type]
4536
config/graphql/conversation_mutations.py:242: error: Argument 2 to "set_permissions_for_obj_to_user" has incompatible type "ChatMessage"; expected "type[Model]" [arg-type]

mypy.ini

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ ignore_errors = True
4646
[mypy-config.admin_auth.views]
4747
ignore_errors = True
4848

49-
# --- config.graphql (35 files) ---
49+
# --- config.graphql (34 files) ---
5050

5151
[mypy-config.graphql.action_queries]
5252
ignore_errors = True
@@ -63,9 +63,6 @@ ignore_errors = True
6363
[mypy-config.graphql.badge_mutations]
6464
ignore_errors = True
6565

66-
[mypy-config.graphql.base]
67-
ignore_errors = True
68-
6966
[mypy-config.graphql.base_types]
7067
ignore_errors = True
7168

opencontractserver/tests/test_security_hardening.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,3 +1205,63 @@ def test_validation_error_list_format(self):
12051205
message = DRFMutation.format_validation_error(exc)
12061206
self.assertIn("Error one.", message)
12071207
self.assertIn("Error two.", message)
1208+
1209+
1210+
# ---------------------------------------------------------------------------
1211+
# DRFMutation / DRFDeletion IOSettings misconfiguration guard (issue #1360)
1212+
# ---------------------------------------------------------------------------
1213+
1214+
1215+
class TestIOSettingsRequiredFieldsGuard(TestCase):
1216+
"""Subclasses that forget to configure IOSettings should fail with a
1217+
clear ``NotImplementedError`` rather than a late ``AttributeError`` /
1218+
``TypeError`` that pretends to be a generic "internal error"."""
1219+
1220+
def test_require_io_setting_raises_when_io_settings_missing(self):
1221+
from config.graphql.base import _require_io_setting
1222+
1223+
class MisconfiguredMutation:
1224+
pass
1225+
1226+
with self.assertRaises(NotImplementedError) as ctx:
1227+
_require_io_setting(MisconfiguredMutation, "model")
1228+
self.assertIn("MisconfiguredMutation", str(ctx.exception))
1229+
self.assertIn("model", str(ctx.exception))
1230+
1231+
def test_require_io_setting_raises_when_attribute_none(self):
1232+
"""Mirrors the inherited ``IOSettings`` on ``DRFMutation`` where
1233+
``model``/``serializer``/``graphene_model`` default to ``None``."""
1234+
from config.graphql.base import _require_io_setting
1235+
1236+
class MisconfiguredMutation:
1237+
class IOSettings:
1238+
model = None
1239+
serializer = None
1240+
graphene_model = None
1241+
1242+
for field in ("model", "serializer", "graphene_model"):
1243+
with self.assertRaises(NotImplementedError) as ctx:
1244+
_require_io_setting(MisconfiguredMutation, field)
1245+
self.assertIn("MisconfiguredMutation", str(ctx.exception))
1246+
self.assertIn(field, str(ctx.exception))
1247+
1248+
def test_require_io_setting_returns_configured_value(self):
1249+
from config.graphql.base import _require_io_setting
1250+
1251+
class ConfiguredMutation:
1252+
class IOSettings:
1253+
model = Corpus
1254+
1255+
self.assertIs(_require_io_setting(ConfiguredMutation, "model"), Corpus)
1256+
1257+
def test_base_iosettings_defaults_are_none_on_mutation(self):
1258+
"""Regression guard: the base ``DRFMutation.IOSettings`` exposes
1259+
``model`` / ``graphene_model`` / ``serializer`` as ``None`` so the
1260+
runtime guard can detect a missing override. ``DRFDeletion`` now
1261+
declares ``model`` the same way (previously not declared at all)."""
1262+
from config.graphql.base import DRFDeletion, DRFMutation
1263+
1264+
self.assertIsNone(DRFMutation.IOSettings.model)
1265+
self.assertIsNone(DRFMutation.IOSettings.serializer)
1266+
self.assertIsNone(DRFMutation.IOSettings.graphene_model)
1267+
self.assertIsNone(DRFDeletion.IOSettings.model)

0 commit comments

Comments
 (0)