Skip to content

Commit 6e4ab5f

Browse files
committed
Merge remote-tracking branch 'origin/main' into pr-1239-clean
2 parents 6689081 + adf0e92 commit 6e4ab5f

3 files changed

Lines changed: 353 additions & 29 deletions

File tree

CHANGELOG.md

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

6666
### Security
6767

68+
- **`RemoveAnnotationLabelsFromLabelsetMutation` allowed any user to strip labels from public labelsets** (`config/graphql/label_mutations.py`): The resolver used `Q(creator=user) | Q(is_public=True)` with no further check, so `is_public` (a read flag) effectively granted write access to non-owners. Replaced with `user_has_permission_for_obj(user, labelset, PermissionTypes.UPDATE, include_group_permissions=True)`. Denial raises `LabelSet.DoesNotExist` so the response is byte-identical to the not-found case (no IDOR information leak). Coverage: `test_remove_labels_rejects_non_owner_even_when_labelset_is_public`.
69+
- **`CreateLabelForLabelsetMutation` ignored guardian UPDATE grants** (`config/graphql/label_mutations.py`): The resolver was creator-only, so collaborators with explicit `update_labelset` guardian permission (or a group grant) could not add labels even though the consolidated permissioning guide says edit rights on a `LabelSet` should permit add/remove/edit of its labels. Now uses the same `PermissionTypes.UPDATE` gate as `Remove`, with the matching IDOR-safe deny path. Coverage: `test_non_owner_with_explicit_update_permission_can_create`. Both mutations now have a dedicated `LabelSet.DoesNotExist` handler that logs at WARNING (not ERROR) so auth probes don't pollute logs with stack traces.
6870
- **Cross-corpus structural-annotation leak in `CoreAnnotationVectorStore`** (`opencontractserver/llms/vector_stores/core_vector_stores.py:296-326,371-413`): The corpus-wide retrieval path (`corpus_id` set, `document_id=None`) returned every structural annotation in the database regardless of corpus. Two collaborating defects caused the leak:
6971
1. `Q(structural=True)` in the corpus-only branch had **no corpus constraint** — parser-produced structural annotations have `Annotation.document_id = corpus_id = NULL`, so corpus membership is only knowable through `structural_set → Document.structural_annotation_set (reverse FK) → DocumentPath.corpus_id`, a join the previous code did not perform.
7072
2. The `check_corpus_deletion` block (default `True`) added `Q(document_id__in=active_doc_ids)`, and `__in` lookups never match `NULL`, so structural annotations were silently dropped on the production-default path. Bypassing this filter with `check_corpus_deletion=False` exposed defect #1 directly.

config/graphql/label_mutations.py

Lines changed: 89 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import graphene
99
from django.conf import settings
1010
from django.core.files.base import ContentFile
11-
from django.db.models import Q
1211
from graphql_jwt.decorators import login_required
1312
from graphql_relay import from_global_id, to_global_id
1413

@@ -20,7 +19,10 @@
2019
from config.graphql.validation_utils import validate_color
2120
from opencontractserver.annotations.models import AnnotationLabel, LabelSet
2221
from opencontractserver.types.enums import PermissionTypes
23-
from opencontractserver.utils.permissioning import set_permissions_for_obj_to_user
22+
from opencontractserver.utils.permissioning import (
23+
set_permissions_for_obj_to_user,
24+
user_has_permission_for_obj,
25+
)
2426

2527
logger = logging.getLogger(__name__)
2628

@@ -221,32 +223,69 @@ class Arguments:
221223

222224
@login_required
223225
def mutate(
224-
root, info, labelset_id, text, description, color, icon, label_type
226+
root,
227+
info,
228+
labelset_id,
229+
text=None,
230+
description=None,
231+
color=None,
232+
icon=None,
233+
label_type=None,
225234
) -> "CreateLabelForLabelsetMutation":
226235

227236
ok = False
228237
obj = None
229238
obj_id = None
230239

231-
# Validate color format (defense in depth)
232-
is_valid_color, color_error = validate_color(color)
233-
if not is_valid_color:
234-
return CreateLabelForLabelsetMutation(
235-
obj=None, obj_id=None, message=color_error, ok=False
236-
)
237-
238240
try:
239-
labelset = LabelSet.objects.get(
240-
pk=from_global_id(labelset_id)[1], creator=info.context.user
241-
)
241+
# Permission check runs before validation so a non-owner cannot
242+
# distinguish "reached validation" from "denied" via different
243+
# error messages (IDOR mitigation — see
244+
# docs/permissioning/consolidated_permissioning_guide.md).
245+
labelset = LabelSet.objects.get(pk=from_global_id(labelset_id)[1])
246+
if not user_has_permission_for_obj(
247+
info.context.user,
248+
labelset,
249+
PermissionTypes.UPDATE,
250+
include_group_permissions=True,
251+
):
252+
raise LabelSet.DoesNotExist()
253+
254+
# Reject blank text explicitly: Django's ``blank=False`` is
255+
# form-only and ``objects.create()`` would silently apply the
256+
# "Text Label" model default.
257+
if not (text and text.strip()):
258+
return CreateLabelForLabelsetMutation(
259+
obj=None,
260+
obj_id=None,
261+
message="Label text is required and cannot be blank.",
262+
ok=False,
263+
)
264+
265+
if color == "":
266+
color = None
267+
is_valid_color, color_error = validate_color(color)
268+
if not is_valid_color:
269+
return CreateLabelForLabelsetMutation(
270+
obj=None, obj_id=None, message=color_error, ok=False
271+
)
272+
242273
logger.debug("CreateLabelForLabelsetMutation - mutate / Labelset", labelset)
274+
# Drop None/"" so model field defaults apply rather than
275+
# writing blank values at the DB level.
276+
create_kwargs = {
277+
k: v
278+
for k, v in {
279+
"text": text,
280+
"description": description,
281+
"color": color,
282+
"icon": icon,
283+
"label_type": label_type,
284+
}.items()
285+
if v is not None and v != ""
286+
}
243287
obj = AnnotationLabel.objects.create(
244-
text=text,
245-
description=description,
246-
color=color,
247-
icon=icon,
248-
label_type=label_type,
249-
creator=info.context.user,
288+
creator=info.context.user, **create_kwargs
250289
)
251290
obj_id = to_global_id("AnnotationLabelType", obj.id)
252291
logger.debug("CreateLabelForLabelsetMutation - mutate / Created label", obj)
@@ -263,7 +302,19 @@ def mutate(
263302
message = "SUCCESS"
264303
logger.debug("Done")
265304

305+
except LabelSet.DoesNotExist:
306+
# Auth rejection or genuine 404 — warn without stack trace.
307+
logger.warning(
308+
"CreateLabelForLabelsetMutation: labelset not found or "
309+
"permission denied (labelset_id=%s)",
310+
labelset_id,
311+
)
312+
message = (
313+
"Failed to create label for labelset due to error: "
314+
"LabelSet matching query does not exist."
315+
)
266316
except Exception as e:
317+
logger.exception("CreateLabelForLabelsetMutation failed")
267318
message = f"Failed to create label for labelset due to error: {e}"
268319

269320
return CreateLabelForLabelsetMutation(
@@ -297,14 +348,29 @@ def mutate(
297348
label_pks = list(
298349
map(lambda graphene_id: from_global_id(graphene_id)[1], label_ids)
299350
)
300-
labelset = LabelSet.objects.get(
301-
Q(pk=from_global_id(labelset_id)[1])
302-
& (Q(creator=user) | Q(is_public=True))
303-
)
351+
labelset = LabelSet.objects.get(pk=from_global_id(labelset_id)[1])
352+
if not user_has_permission_for_obj(
353+
user,
354+
labelset,
355+
PermissionTypes.UPDATE,
356+
include_group_permissions=True,
357+
):
358+
raise LabelSet.DoesNotExist()
304359
labelset.annotation_labels.remove(*label_pks)
305360
ok = True
306361
message = "Success"
307362

363+
except LabelSet.DoesNotExist:
364+
# Auth rejection or genuine 404 — warn without stack trace.
365+
logger.warning(
366+
"RemoveLabelsFromLabelsetMutation: labelset not found or "
367+
"permission denied (labelset_id=%s)",
368+
labelset_id,
369+
)
370+
message = (
371+
"Error removing label(s) from labelset: "
372+
"LabelSet matching query does not exist."
373+
)
308374
except Exception as e:
309375
logger.exception("RemoveLabelsFromLabelsetMutation failed")
310376
message = f"Error removing label(s) from labelset: {e}"

0 commit comments

Comments
 (0)