Skip to content

Commit 0bbaa64

Browse files
authored
Merge pull request #2032 from Open-Source-Legal/claude/eager-ritchie-wfd5yi
Permissioning audit follow-ups (#1986): conversation/message visibility parity, safe manager fallback, datacell crash
2 parents ea65fc1 + 8c2c435 commit 0bbaa64

8 files changed

Lines changed: 542 additions & 11 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
- Fixed an `AttributeError` crash in `Datacell._validate_manual_entry`
2+
(`opencontractserver/extracts/models.py`, issue #1986 item 7). The "required"
3+
check in the missing-`value` branch called
4+
`self.column.validation_config.get("required")` *before* the
5+
`config = self.column.validation_config or {}` guard, so validating a
6+
manual-entry datacell with no `value` key on a column whose `validation_config`
7+
is `None` (a nullable JSONField — legitimately null on explicitly-cleared or
8+
legacy rows) raised `'NoneType' object has no attribute 'get'`. The `or {}`
9+
guard is now resolved once at the top of the method and reused, so a config-less
10+
column validates cleanly (nothing is required) while a column that *does* set
11+
`required` still raises the expected `ValidationError`.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
- Permissioning audit follow-ups from issue #1986 (filter/check parity + safe
2+
error handling):
3+
- **Anonymous chat-message visibility now follows the conversation
4+
bifurcation (item 2).** `ChatMessageQuerySet.visible_to_user`
5+
(`opencontractserver/conversations/models.py`) previously filtered the
6+
anonymous branch on `conversation__is_public=True` alone, which exposed a
7+
public **CHAT**'s messages even though `ConversationQuerySet.visible_to_user`
8+
keeps the conversation itself hidden from anonymous users (anonymous can
9+
only see THREADs). The branch now delegates to
10+
`Conversation.objects.visible_to_user`, so message visibility inherits the
11+
single-source-of-truth CHAT/THREAD rules — no public-CHAT message leak, and
12+
messages in context-inherited public-resource threads are correctly visible.
13+
- **Conversation and chat-message querysets now honour group-level READ
14+
grants (item 3).** `ConversationQuerySet` / `ChatMessageQuerySet`
15+
`visible_to_user` consulted only the USER object-permission tables. Because
16+
`ConversationManager.user_can(READ)` / `ChatMessageManager.user_can(READ)`
17+
route back through `visible_to_user`, a group-only READ grant was both
18+
invisible in lists AND denied by `user_can(READ)` — even though non-READ
19+
writes (via `_default_user_can`) honoured the same group grant. Both
20+
querysets now join the `*groupobjectpermission` tables (mirroring
21+
`BaseVisibilityManager`), closing the same group-grant gap PR #1985 fixed for
22+
annotations / relationships / extracts.
23+
- **`BaseVisibilityManager.visible_to_user` no longer swallows unexpected
24+
errors (item 4).** The guardian-lookup fallback caught
25+
`(ImportError, Exception)` — equivalent to a bare `except Exception` — so any
26+
error silently degraded the queryset to creator/public filtering,
27+
under-disclosing guardian-granted rows while hiding the defect
28+
(`opencontractserver/shared/Managers.py`). The catch is narrowed to
29+
`(ImportError, LookupError)` so a genuinely-absent permission table still
30+
falls back gracefully, but programming/database errors now propagate.

opencontractserver/conversations/models.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,15 @@ def visible_to_user(
223223
)
224224
return queryset.filter(anon_filter).distinct().order_by("-created")
225225

226-
# Get explicitly permitted conversation IDs via guardian
226+
# Get explicitly permitted conversation IDs via guardian (user- AND
227+
# group-level READ grants). ``ConversationManager.user_can(READ)``
228+
# routes back through this queryset, so the filter IS the check — but
229+
# non-READ writes delegate to ``_default_user_can``, which honours group
230+
# grants by default. Consulting only the USER object-permission table
231+
# here therefore made a group-only READ grant entirely non-functional:
232+
# invisible in lists AND denied by ``user_can(READ)`` (issue #1986
233+
# item 3 — the same filter/check group-grant gap PR #1985 closed for
234+
# annotations / relationships / extracts).
227235
model_name = self.model._meta.model_name
228236
app_label = self.model._meta.app_label
229237

@@ -236,9 +244,28 @@ def visible_to_user(
236244
except LookupError:
237245
permitted_ids = []
238246

247+
# Group-level READ grants, resolved in their own ``try`` so a missing
248+
# group table never discards the already-resolved user-level grants.
249+
# The lazy ``values_list`` keeps both lookups as SQL subqueries
250+
# (no extra round-trips).
251+
try:
252+
group_permission_model_name = f"{model_name}groupobjectpermission"
253+
group_permission_model_type = apps.get_model(
254+
app_label, group_permission_model_name
255+
)
256+
group_permitted_ids = group_permission_model_type.objects.filter(
257+
permission__codename=f"read_{model_name}",
258+
group_id__in=user.groups.values_list("id", flat=True),
259+
).values_list("content_object_id", flat=True)
260+
except LookupError:
261+
group_permitted_ids = []
262+
239263
# Base conditions: apply to BOTH CHAT and THREAD types
240264
base_conditions = (
241-
Q(creator_id=user.id) | Q(is_public=True) | Q(id__in=permitted_ids)
265+
Q(creator_id=user.id)
266+
| Q(is_public=True)
267+
| Q(id__in=permitted_ids)
268+
| Q(id__in=group_permitted_ids)
242269
)
243270

244271
# CHAT type: base conditions only (restrictive)
@@ -374,9 +401,25 @@ def visible_to_user(
374401

375402
# Superusers are computed like any other user (scoped admin access, 2026-05) — no blanket bypass.
376403

377-
# Anonymous users only see messages in public conversations
404+
# Anonymous users see messages only in conversations THEY can see.
405+
# Delegating to ``Conversation.objects.visible_to_user`` keeps the
406+
# CHAT/THREAD bifurcation the single source of truth (issue #1986
407+
# item 2): for an anonymous viewer that is public THREADs plus context
408+
# inheritance on public corpuses/documents, and NEVER a CHAT. The
409+
# previous ``conversation__is_public=True`` shortcut both leaked a
410+
# public *CHAT*'s messages (the conversation itself stays hidden via
411+
# ``ConversationQuerySet.visible_to_user``, so the messages were the
412+
# odd surface out) and missed messages in context-inherited
413+
# public-resource threads. This mirrors the authenticated branch
414+
# below, which already routes message visibility through conversation
415+
# visibility.
378416
if user.is_anonymous:
379-
return queryset.filter(conversation__is_public=True).order_by("created")
417+
visible_conversation_ids = Conversation.objects.visible_to_user(
418+
user
419+
).values_list("id", flat=True)
420+
return queryset.filter(
421+
conversation_id__in=visible_conversation_ids
422+
).order_by("created")
380423

381424
# Primary visibility: messages in visible conversations
382425
# This inherits the bifurcated CHAT/THREAD permission logic
@@ -386,7 +429,12 @@ def visible_to_user(
386429
).values_list("id", flat=True)
387430
conversation_visible = Q(conversation_id__in=visible_conversation_ids)
388431

389-
# Additional conditions for explicit message-level access
432+
# Additional conditions for explicit message-level access (user- AND
433+
# group-level guardian grants). Adding the group branch mirrors the
434+
# conversation queryset's group-grant fix (issue #1986 item 3) so a
435+
# message shared via a Django group is visible in lists, not only to
436+
# direct user grantees. Both branches preserve the existing
437+
# any-message-permission shape (a grant of any codename implies READ).
390438
from django.apps import apps
391439

392440
try:
@@ -401,9 +449,22 @@ def visible_to_user(
401449
except LookupError:
402450
has_permission = Q(pk__in=[])
403451

452+
try:
453+
group_permission_model = apps.get_model(
454+
"conversations", "chatmessagegroupobjectpermission"
455+
)
456+
has_group_permission = Q(
457+
id__in=group_permission_model.objects.filter(
458+
group_id__in=user.groups.values_list("id", flat=True)
459+
).values_list("content_object_id", flat=True)
460+
)
461+
except LookupError:
462+
has_group_permission = Q(pk__in=[])
463+
404464
base_conditions = (
405465
Q(creator=user) # User created the message
406-
| has_permission # User has explicit permission on message
466+
| has_permission # User has explicit user-level permission
467+
| has_group_permission # User has a group-level permission
407468
)
408469

409470
# Moderator conditions: user can moderate the conversation

opencontractserver/extracts/models.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -404,16 +404,20 @@ def _validate_manual_entry(self) -> None:
404404
if not self.data:
405405
self.data = {}
406406

407+
# ``validation_config`` is a nullable JSONField, so resolve the ``{}``
408+
# fallback ONCE before reading any key — calling ``.get(...)`` on a
409+
# ``None`` config is the AttributeError fixed in issue #1986 item 7.
410+
config = self.column.validation_config or {}
411+
407412
if "value" not in self.data:
408-
if self.column.validation_config.get("required"):
413+
if config.get("required"):
409414
raise django.core.exceptions.ValidationError(
410415
f"{self.column.name} is required"
411416
)
412417
return
413418

414419
value = self.data["value"]
415420
data_type = self.column.data_type
416-
config = self.column.validation_config or {}
417421

418422
# Skip further validation if value is None
419423
if value is None:

opencontractserver/shared/Managers.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,19 @@ def visible_to_user(
338338

339339
return queryset
340340

341-
except (ImportError, Exception) as e:
342-
# Fall back to creator/public check only if Guardian not available or error
341+
except (ImportError, LookupError) as e:
342+
# Narrow, intentional fallback (issue #1986 item 4): degrade to
343+
# creator/public filtering ONLY when the guardian permission tables
344+
# are genuinely absent for this model — ``ImportError`` (guardian /
345+
# an app not installed) or ``LookupError`` (``apps.get_model`` cannot
346+
# resolve the permission model). The previous ``(ImportError,
347+
# Exception)`` envelope was equivalent to a bare ``except Exception``
348+
# (``ImportError`` ⊂ ``Exception``): ANY error — a programming bug, a
349+
# database error, a malformed grant row — was swallowed and the
350+
# queryset silently dropped every guardian-granted row, UNDER-
351+
# disclosing (a fail-open-to-less-data security smell) while hiding
352+
# the defect. Such errors now propagate so they surface in tests and
353+
# monitoring instead of quietly narrowing a user's visible set.
343354
logger.debug(
344355
f"Could not use Guardian permissions for {self.model.__name__}: {e}. "
345356
f"Using creator/public filtering only."

0 commit comments

Comments
 (0)