Permissioning audit follow-ups (#1986): conversation/message visibility parity, safe manager fallback, datacell crash#2032
Conversation
…ty parity, safe manager fallback, datacell crash Addresses the surgical security + correctness items from the #1986 tracking issue (remaining items are dispositioned in the PR description): - Item 2: ChatMessageQuerySet's anonymous branch now delegates to Conversation.objects.visible_to_user instead of filtering on conversation__is_public alone, so anonymous message visibility follows the CHAT/THREAD bifurcation — no public-CHAT message leak (the conversation itself stays hidden) and context-inherited public-resource thread messages are now visible. - Item 3: ConversationQuerySet / ChatMessageQuerySet now join the *groupobjectpermission tables. Because user_can(READ) for these models routes back through visible_to_user, a group-only READ grant was previously non-functional (invisible in lists AND denied by user_can), even though non-READ writes honoured the same grant. Closes that filter/check gap to match annotations/relationships/extracts. - Item 4: BaseVisibilityManager.visible_to_user narrows its guardian-lookup fallback from (ImportError, Exception) — a bare except Exception — to (ImportError, LookupError), so a genuinely-missing permission table still degrades gracefully but programming/database errors propagate instead of silently under-disclosing guardian-granted rows. - Item 7: Datacell._validate_manual_entry hoists the `config = ... or {}` guard above its first use, fixing an AttributeError when a manual-entry cell with no `value` key is validated on a column whose validation_config is None. Adds regression tests for each item and changelog fragments.
Code Review — PR #2032Overall this is a well-scoped, high-quality security PR. Four surgical fixes with targeted tests. I've gone through each change carefully and have a mix of compliments, minor concerns, and one correctness observation. Item 2 — Anonymous message visibility (
|
| Item | Assessment |
|---|---|
| Item 2 (anon message bifurcation) | ✅ Correct, note the subquery depth cost |
| Item 3 (group-level READ grants) | ✅ Correct; confirm message-without-conversation UX is intentional |
| Item 4 (exception narrowing) | ✅ Correct, well-tested |
| Item 7 (datacell crash) | ✅ Correct, clean fix |
| Test coverage | ✅ All four items have targeted regression tests with both positive and negative assertions |
| Changelog | ✅ Meets project standards |
No blocking issues. The two questions worth answering before merge:
- Is it intentional that a group-level message grant grants visibility even when the parent conversation isn't visible to that group?
- Is the nested-subquery cost for anonymous message queries acceptable (or should a perf note be added to the method)?
|
Thanks for the thorough review. Both flagged questions are about deliberate, pre-existing behavior, so no code change — but to put the rationale on record: Q1 — a group-level message grant is visible even when the parent conversation isn't. This is the existing message-level access path, not new in this PR. The authenticated branch already ORs the user-level direct message grant with conversation visibility: return queryset.filter(conversation_visible | base_conditions | moderator_conditions)
# ^ base_conditions already contains has_permission (user-level message grant)So a message shared directly (without sharing its conversation) was already visible to a user-level grantee. Item 3 only extends that same OR to group grants, for filter/check parity. Switching OR→AND would change established user-level semantics and drop legitimate direct-share access — out of scope for this audit follow-up. (Whether the frontend lets you navigate to the parent conversation is a separate UI concern; the backend correctly exposes what was explicitly shared.) Q2 — anonymous subquery cost. The new anonymous path runs the identical subquery the long-standing authenticated path already runs ( Minor: the redundant Generated by Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
codecov/patch flagged 4 uncovered lines in conversations/models.py — the defensive `except LookupError` branches added for item 3 (group-grant visibility) in ConversationQuerySet and ChatMessageQuerySet. These fall back to empty group grants when the *groupobjectpermission table can't be resolved, and never fire in normal tests because the tables always exist. Add two regression tests that surgically patch apps.get_model to raise LookupError for only the conversation / chat-message group-permission model, asserting the queryset degrades gracefully (group grant ignored, no crash) — mirroring the item-4 missing-table test in test_shared_managers.py. This covers all four lines, taking patch coverage to ~100%.
Code ReviewOverviewThis PR surgically addresses four items from the #1986 permissioning audit: an anonymous Item 2 - Anonymous chat-message visibility (security)Fix is correct. Delegating the anonymous branch to Minor performance trade-off: the old Item 3 - Group-level READ grants (security)Fix is correct and mirrors what PR #1985 did for annotations/extracts. One pre-existing inconsistency this PR intentionally preserves (and the comment correctly documents): conversation-level group grants filter by Item 4 -
|
…tion Per review feedback on PR #2032: test_missing_conversation_group_table_falls_ back_gracefully previously only asserted the negative (a group-only grant vanishes when the group permission table is unreadable). Add a second conversation reachable via a USER-level grant and assert it STILL appears under the same simulated-missing-group-table condition. This pins the guarantee the ConversationQuerySet comment makes — the user- and group-level lookups live in separate try blocks precisely so a missing group table never discards already-resolved user-level grants.
|
Thanks — incorporated the one actionable item and clarifying the rest:
Generated by Claude Code |
Code Review — PR #2032Permissioning audit follow-ups (#1986): conversation/message visibility parity, safe manager fallback, datacell crash Overall this is a high-quality, surgical PR. The fixes are well-reasoned, the changelog fragments follow convention, and the test coverage is solid. A few things worth addressing before merge: CorrectnessItem 2 — anonymous path now runs a richer subquery than necessary
visible_conversation_ids = Conversation.objects.visible_to_user(user).values_list("id", flat=True)
return queryset.filter(conversation_id__in=visible_conversation_ids).order_by("created")
visible_conversation_ids = (
Conversation.objects.visible_to_user(user)
.order_by() # strip ordering — irrelevant in a subquery
.values_list("id", flat=True)
)Item 7 — correct fix, but a comment explains what rather than why
Test coverage gap
def test_conversation_group_grant_makes_messages_visible(self):
chat = Conversation.objects.create(
title="Owner's Chat", creator=self.owner,
conversation_type=ConversationTypeChoices.CHAT,
)
message = ChatMessage.objects.create(
conversation=chat, creator=self.owner,
msg_type=MessageTypeChoices.HUMAN, content="via-conv-grant",
)
self.assertNotIn(message, ChatMessage.objects.visible_to_user(self.member))
assign_perm("read_conversation", self.group, chat) # conversation-level grant
self.assertIn(message, ChatMessage.objects.visible_to_user(self.member))
self.assertNotIn(message, ChatMessage.objects.visible_to_user(self.outsider))This is the primary path users will hit; pinning it prevents a regression from silently removing the Minor style/consistency notes
What's working well
Verdict: Approve with the subquery |
Address review feedback on PR #2032: - Add test_conversation_group_grant_makes_messages_visible: pins the common path where a group grant on a *conversation* surfaces its *messages* via the conversation_visible inheritance branch (distinct from the existing direct message-level grant test). Guards the integration of the item-3 conversation group-grant fix with message visibility against silent regression. - Trim the Datacell._validate_manual_entry comment to the invariant (nullable config -> resolve the {} fallback once before reading any key); the historical detail lives in the changelog fragment and commit history. Other review points are intentional/pre-existing (no change): the anonymous subquery mirrors the long-standing authenticated path verbatim (stripping order_by on only one branch would create asymmetry), and the message group branch's id__in / pk__in shapes already match the existing user-level branch.
|
Addressed in
Two points I'm intentionally not changing:
The Generated by Claude Code |
Code Review — PR #2032: Permissioning audit follow-ups (#1986)Well-crafted, surgical PR. The four fixes are correctly targeted, each paired with a test, and the disposition table for the remaining #1986 items is clear and useful. Findings below, roughly in priority order. Security / CorrectnessItem 4 ( The narrowing from Item 2 (anonymous Delegating to Code Style / Consistency
In # ConversationQuerySet (derived — ✓):
group_permission_model_type = apps.get_model(app_label, group_permission_model_name)
# ChatMessageQuerySet (hard-coded — minor inconsistency):
group_permission_model = apps.get_model("conversations", "chatmessagegroupobjectpermission")Not a bug (the app label is correct), but using Codename filtering asymmetry (pre-existing, not introduced here — just flagging)
Test CoverageTests are thorough and follow project conventions well. Three minor observations:
Minor / Nitpicks
SummaryAll four fixes are correct. The exception narrowing (item 4) is the most impactful change and the one with the widest blast radius — confident it's right, just worth the second read on what else lives inside that No blocking issues. The app-label hard-coding and redundant |
Per review feedback on PR #2032: test_missing_message_group_table_falls_back_ gracefully now also asserts that a USER-level message grant survives when the chat-message group-permission table is unreadable, mirroring the conversation counterpart strengthened earlier. Pins that the separate user/group try blocks keep user-level message grants intact when the group lookup degrades.
|
Addressed in
Intentionally not changing (evaluated, declined):
This is my last substantive change — all three reviews approved with no blockers, and the remaining notes are nitpicks I've evaluated. I'll keep monitoring CI to merge-readiness. Generated by Claude Code |
Code ReviewOverall this is a well-scoped, high-quality security PR. The four fixes are surgical, individually easy to reason about, and the test suite is thorough. Notes are ordered roughly from most to least significant. Item 4 — BaseVisibilityManager exception narrowing (shared/Managers.py)Correct and important. The old
One observation: both the inner Item 2 — Anonymous message visibility (ChatMessageQuerySet)Correct fix. Delegating to Minor performance note: the anonymous path now resolves a full conversation subquery (with public-corpus/document joins) instead of a single Item 3 — Group-level READ grants (ConversationQuerySet / ChatMessageQuerySet)Pattern is correct and matches BaseVisibilityManager. The separate Minor inconsistency: No codename filter on messages (intentional): In Item 7 — Datacell._validate_manual_entry (extracts/models.py)Correct one-liner fix. Hoisting TestsCoverage is excellent across all four items:
Minor nits:
Comments/styleCLAUDE.md says to default to no inline comments and avoid multi-line comment blocks. The new code adds several multi-line blocks explaining the "why" of each security change. For permissioning code the context is genuinely valuable — these are non-obvious security invariants — but some of the more recap-heavy paragraphs could be trimmed. Not a blocker. SummaryThe security fixes are correct, the tests are comprehensive, and the PR stays appropriately surgical. Two items worth addressing before merge: (1) add |
Summary
Addresses the surgical, still-present security + correctness items from the #1986 permissioning-audit tracking issue. #1986 lists 14 follow-ups of widely varying scope; this PR takes the four that are unambiguous, high-value, and genuinely surgical, and dispositions the rest below (rather than forcing risky/architectural changes into one PR — per the "don't run wild" guidance).
Refs #1986 (intentionally not
Closes, since 14 items remain on the tracker).Fixes in this PR
Item 2 — anonymous chat-message visibility now follows the CHAT/THREAD bifurcation (security)
ChatMessageQuerySet.visible_to_user's anonymous branch filteredconversation__is_public=Truealone (opencontractserver/conversations/models.py). ButConversationQuerySet.visible_to_userkeeps a public CHAT hidden from anonymous users (anonymous can only see THREADs), so a public CHAT's messages were anonymous-visible while the conversation itself was not. The branch now delegates toConversation.objects.visible_to_user— the single source of truth — so messages inherit the bifurcation. This also fixes a latent under-disclosure: messages in context-inherited public-resource threads are now visible, matching conversation visibility and the authenticated path (which already delegates the same way).Item 3 — conversation/message querysets honour group-level READ grants (security)
ConversationQuerySet/ChatMessageQuerySetconsulted only the user-level object-permission tables. BecauseConversationManager.user_can(READ)/ChatMessageManager.user_can(READ)route back throughvisible_to_user, a group-only READ grant was entirely non-functional for these models — invisible in lists and denied byuser_can(READ)— even though non-READ writes (_default_user_can) honour the same group grant. Both querysets now join the*groupobjectpermissiontables (mirroringBaseVisibilityManager), closing the same filter/check group-grant gap PR #1985 closed for annotations/relationships/extracts.Item 4 —
BaseVisibilityManager.visible_to_userstops swallowing unexpected errors (security)The guardian-lookup fallback caught
(ImportError, Exception)— equivalent to a bareexcept Exception(ImportError ⊂ Exception). Any error (programming bug, DB error, malformed grant row) silently degraded the queryset to creator/public filtering, under-disclosing guardian-granted rows while hiding the defect (opencontractserver/shared/Managers.py). Narrowed to(ImportError, LookupError): a genuinely-absent permission table still degrades gracefully, but real errors now propagate.Item 7 —
Datacell._validate_manual_entryAttributeError (correctness)The "required" check read
self.column.validation_config.get("required")before theconfig = ... or {}guard (opencontractserver/extracts/models.py).validation_configis a nullable JSONField, so validating a manual-entry cell with novaluekey on a config-less column raised'NoneType' object has no attribute 'get'. Theor {}guard is hoisted and reused.Tests
test_conversation_permissions.py: anonymous message bifurcation (item 2) + newTestConversationGroupGrants(item 3, asserting filter↔user_canparity for both conversations and messages).test_shared_managers.py: item 4 — unexpected error propagates; missing permission table still falls back gracefully (both halves of the narrowed envelope).test_metadata_columns.py: item 7 — null-config cell validates cleanly; arequiredconfig still raises.Validation note:
python -m compileall,black --check,flake8, andscripts/collate_changelog.py --checkall pass locally. The dockerized test suite could not be executed in this environment — base-image pulls hit Docker Hub's unauthenticated rate limit (HTTP 429) — so the new/affected tests rely on CI to run. They're written against the existing fixtures/patterns in each file.Disposition of the remaining #1986 items (not in this PR)
AnnotationManager.user_cannow mirrors the queryset'sQ(creator=user)source-privacy exemption; the sentinel was replaced bytest_annotation_creator_source_private_row_has_filter_check_parity).ExtractTypehas noget_querysetget_querysetdoes not guard single-FK traversal (the citedDatacellType.extractcase), and the real protections (get_node+ service-filteredresolve_extracts/resolve_analyses) already exist; adding it risks double-filtering connections. Marginal defense-in-depth value vs. regression risk — wants its own PR + test design.DocumentRelationshipServicemyPermissions under-reportuser_has_permissionis correct; only the UI affordance under-reports). A correct fix needs per-permission editable-by-user subqueries (create/update/delete × source/target doc + corpus) — a new queryset concept that's not surgical. Own PR.analysis_id == 0sentinel__none__mapping). Separate PR.require_permissiontruthiness inversiontest_query_optimizer_methods.pysource_visibility.pymicro-dedupclaude-reviewworkflow malfunction🤖 Generated with Claude Code
Generated by Claude Code