Phase D: standardize IDOR pattern via get_for_user_or_none (#1658)#1693
Conversation
Adds a generic non-corpus-scoped lookup helper in opencontractserver/utils/permissioning.py that collapses 'pk does not exist' and 'pk exists but caller lacks READ' into the same None return value, so mutation call sites can render a single unified 'not found or no permission' message and stop leaking object existence via differential error text. For document lookups in a corpus context, callers continue to use CorpusObjsService.get_corpus_document_by_id (PR #1685) — the helper's docstring cross-references that to prevent two parallel IDOR-safe APIs from drifting. Unit tests pin all six branches: creator, public, missing pk, inaccessible pk, explicit grant unlock, superuser, anonymous (private vs public), garbage pk input, None pk.
UpdateExtractMutation: collapse the Extract / Corpus / Fieldset
fetch-then-permission-check sequence onto get_for_user_or_none so
the response is identical whether the pk doesn't exist or the
caller lacks the required permission. Removes three try/except
DoesNotExist blocks and the dev-leftover print() trace.
CreateExtractIteration: same pattern on the source extract
lookup — previously a non-existent pk and a no-READ pk returned
different messages ('Source extract not found.' vs 'You don't
have permission to read this extract.').
Also makes get_for_user_or_none generic over the model TypeVar
so mypy preserves the concrete model attributes through the
helper (extract.fieldset, extract.corpus, etc. type-check).
Previously the three branches raised three different exception types depending on whether the analysis didn't exist, was hidden from the caller, or existed-and-was-readable-but-not-deletable. That leaked object existence to anyone with a guessable pk. Now: a single not_found_msg propagates through both the helper's None-return and the DELETE permission check, so a missing pk and an inaccessible pk are indistinguishable to the caller. Lock-conflict path stays its own message — the lock is observable state to anyone who already has READ, so it doesn't leak.
CreateAgentConfigurationMutation / UpdateAgentConfigurationMutation / DeleteAgentConfigurationMutation already returned the same '<resource> not found' message for the 'pk hidden' and 'pk visible-but-no-CRUD' branches, but each one rolled its own visible_to_user(user).get(pk=...) + try/except DoesNotExist boilerplate. Route them through get_for_user_or_none so the fetch-and-gate logic lives in one place. Behaviour unchanged: tests in opencontractserver/tests/test_agents.py all pass (one pre-existing failure on main — TestSearchAgentsForMention::test_search_agents_returns_global_agents — is unrelated to this change and was confirmed by reverting agent_mutations.py and rerunning).
Migrated 8 sites: UpdateCorpusMutation (preferred_embedder pre-check that previously leaked corpus existence to unauthorised callers via the has_documents early-exit), UpdateCorpusDescription, DeleteCorpusMutation (is_personal pre-check), StartCorpusFork, ReEmbedCorpus, CreateCorpusAction, AddTemplateToCorpus, and ToggleCorpusMemory. Each now uses get_for_user_or_none so a missing pk and an inaccessible pk collapse to the same response. SetCorpusVisibility deliberately retains its bare Corpus.objects.get because the mutation supports a sharing pattern where a user is granted PermissionTypes.PERMISSION without an explicit READ codename — pinned by test_user_with_permission_perm_can_change_visibility. get_for_user_or_none filters by visible_to_user (READ codename), which would lock that path out. The bare lookup keeps the PERMISSION-without-READ shortcut alive, and the mutation is still IDOR-safe at the response level because both not-found and no-permission branches return the same message. Inline rationale added to the SetCorpusVisibility mutate body. AddTemplateToCorpus loses its dead except Corpus.DoesNotExist handler now that the lookup goes through the helper instead of raising.
AwardBadgeMutation Badge + Corpus lookups now route through the helper. Both originally used Badge.objects.visible_to_user(user).get() / Corpus.objects.visible_to_user(user).get() with try/except DoesNotExist boilerplate — collapsing the fetch and READ-gate into get_for_user_or_none keeps the same behaviour with less code. User (recipient) lookup deliberately retains the bare User.objects.get because UserProfileManager.visible_to_user has no superuser bypass — applying the helper would lock superuser awarders out from awarding badges to private-profile users. Inline rationale added to the call site. UserBadge (RevokeBadgeMutation) lookup retains the bare ORM call because UserBadge has no visible_to_user manager (join table).
…1658) AnnotationLabel uses creator-based permissions (no guardian tables) so BaseVisibilityManager.visible_to_user resolves to creator/public/superuser — exactly the gate the existing post-fetch check enforced. Route the AnnotationLabel lookup through get_for_user_or_none so the missing-pk and not-the-creator branches converge on the same 'Label not found' response. LabelSet lookups in CreateLabelForLabelsetMutation and RemoveLabelsFromLabelsetMutation deliberately keep their bare LabelSet.objects.get because the mutations support a collaborator pattern where a non-owner has guardian UPDATE on the labelset without an explicit READ codename (pinned by test_non_owner_with_explicit_update_permission_can_create and test_remove_labels_allows_non_owner_with_explicit_update_permission). get_for_user_or_none would filter those collaborators out via visible_to_user (READ codename). Inline rationale at both sites.
Six call sites consolidated onto the helper: - CreateThreadMutation: Corpus + Document lookups - CreateThreadMessageMutation: Conversation lookup - ReplyToMessageMutation: parent ChatMessage lookup - DeleteConversationMutation: Conversation lookup - DeleteMessageMutation: ChatMessage lookup Each previously did Model.objects.visible_to_user(user).get(pk=...) with try/except DoesNotExist boilerplate — the helper compresses the IDOR-safe pattern to one line and lifts the gate logic into a single audited surface. UpdateMessageMutation's lookup is intentionally left untouched: it uses select_related to avoid N+1s on conversation/corpus/creator, and that prefetch shape doesn't compose with the helper's visible_to_user(user).filter(pk=pk).first() chain. The site is already IDOR-safe via the visible_to_user filter.
The only bare Document.objects.get(pk=...) followed by a READ check in document_mutations.py. The remaining sites in this file either route through a corpus context (which the issue scopes to CorpusObjsService) or already use visible_to_user(user).get(pk=...). The local user_has_permission_for_obj(READ) check that gated the 'document not found' response post-fetch is now subsumed by the helper's visible_to_user filter — same observable behaviour for private / shared / public documents.
…one (#1658) CreateDocumentRelationship: route the Corpus / Document lookups through the helper. The downstream user_has_permission_for_obj checks (CREATE on each of corpus, source doc, target doc) stay in place — the helper just collapses the 'pk missing' and 'pk hidden' branches into one None so the IDOR-safe response message ('Corpus not found', 'Source document not found', etc.) is reached the same way regardless of which gate failed. UpdateDocumentRelationship.corpus_id update path: same pattern. The corpus-membership check at L386 still uses CorpusObjsService.get_corpus_documents per PR #1685 so the service-layer rule from CLAUDE.md is preserved.
#1658) Both mutations did a Corpus.objects.visible_to_user(user).get() wrapped in try/except DoesNotExist with a unified IDOR-safe message. Compress the fetch-and-gate logic onto the helper and drop the now-dead except handlers. Both still delegate the per-document corpus-membership and permission checks to DocumentFolderService — that service-layer boundary is left intact.
The three sites previously kept on bare Model.objects.get encoded a permission-model assumption that the user has explicitly retired: granting PERMISSION/UPDATE without an explicit READ codename, or restricting User profile visibility from superusers performing admin operations. Both are now treated as mistakes. Migration: - config/graphql/corpus_mutations.py: SetCorpusVisibility now routes the Corpus lookup through get_for_user_or_none. A caller with PERMISSION (or UPDATE, or DELETE) but no READ no longer reaches the visibility-toggle path. Malformed IDs collapse to the unified ok=False response instead of bubbling. - config/graphql/label_mutations.py: CreateLabelForLabelsetMutation and RemoveLabelsFromLabelsetMutation route the LabelSet lookup through get_for_user_or_none. A collaborator with UPDATE but no READ no longer reaches the create/remove path. - opencontractserver/users/models.py: UserProfileManager.visible_to_user now has a superuser bypass mirroring BaseVisibilityManager — administrative paths (badge awarding) reach private-profile users while non-superuser callers still only see own + public profiles. - config/graphql/badge_mutations.py: AwardBadgeMutation routes the recipient User lookup through get_for_user_or_none. With the new superuser bypass on the manager, superuser awarders still reach private-profile users. Test updates pin the new contract: - test_user_with_permission_perm_can_change_visibility grants [READ, PERMISSION] (was [PERMISSION]). - test_non_owner_with_explicit_update_permission_can_create grants [READ, UPDATE] (was [UPDATE]). - test_remove_labels_allows_non_owner_with_explicit_update_permission grants [READ, UPDATE] (was [UPDATE]). - test_invalid_id_format_returns_error now asserts the unified ok=False/'not found or you don't have permission' response rather than a GraphQL errors entry, matching the helper's ValueError/TypeError swallowing for garbage pks. Verification: 115 passing tests across the affected files (test_corpus_visibility, test_label_mutations, test_badges, test_badge_visibility, test_user_visibility). Pre-commit clean. CHANGELOG updated to document the rule + the superuser bypass and drop the now-stale 'deliberately retained' section.
Phase B (#1688) and Phase C (#1689) landed on main after this branch forked, converting user_has_permission_for_obj call sites to obj.user_can(user, perm, request=info.context) across the mutation files. Phase D's IDOR-safe helper migration touched many of the same files, so the merge surfaced 9 content conflicts. Resolution strategy: keep Phase D's get_for_user_or_none lookups verbatim, then mechanically convert every remaining user_has_permission_for_obj call introduced by Phase D's own edits to the Phase B/C user_can API. Concretely: user_has_permission_for_obj(user, obj, perm, include_group_permissions=True) → obj.user_can(user, perm, request=info.context) Where Phase D's code wrapped a user_has_permission_for_obj call in a defensive 'not user.is_superuser and' / 'creator == user' short-circuit, those are dropped because user_can already handles superuser and creator branches internally via _default_user_can (Phase A's BaseVisibilityManager.user_can). Files reconciled: - agent_mutations.py: 3 sites - analysis_mutations.py: 1 site (DeleteAnalysisMutation) - badge_mutations.py: 2 sites (corpus CRUD checks on Award/Revoke) - conversation_mutations.py: 4 sites - corpus_mutations.py: 5 sites (SetCorpusVisibility, StartCorpusFork, CreateCorpusAction, AddTemplateToCorpus, ToggleCorpusMemory) - document_mutations.py: 5 sites (RetryDocumentProcessing, ImportZipToCorpus, StartCorpusExport, RestoreDocumentToVersion x2) - document_relationship_mutations.py: 4 sites - extract_mutations.py: 3 sites (CreateMetadataColumn, UpdateMetadataColumn, UpdateExtractMutation) - label_mutations.py: 2 sites (CreateLabelForLabelset, RemoveLabels) In every case the prior Phase D structural change (helper-based IDOR-safe lookup, READ-as-precondition for non-READ ops, unified not-found message) is preserved. CHANGELOG.md auto-merged cleanly.
Code Review — Phase D: IDOR-safe
|
…1658) - Restore is_new=True + request=info.context on every creation site stripped during the migration (agent / badge / conversation / label / extract creation paths and _clone_fieldset_for_iteration). Avoids ~7 DB ops per create and the N*(N+1) blow-up on FIELDSET-axis iteration clones flagged in the Phase D review. - DeleteAnalysisMutation: route the IDOR not-found / no-permission / no-DELETE branches through the unified ok=False envelope (matches every other Phase-D-migrated mutation). Lock-held branch also moves to ok=False for response-shape consistency. Helper test still pins the success path so the existing regression remains intact. - DeleteCorpusMutation: short-circuit the IDOR path with the model's own DoesNotExist instead of falling through to super().mutate(), which would have triggered a second visibility-filtered query on the same pk. - StartCorpusFork: shorten the defense-in-depth comment to a single line referencing #1658 so the code reads more cleanly. - Pin the AwardBadgeMutation private-profile semantic in TestBadgeGraphQLMutations.test_non_superuser_cannot_award_to_private_profile_recipient: a non-superuser corpus owner gets the unified "User not found" envelope when the recipient profile is private, and a superuser bypasses the filter and succeeds with the same payload. Locks the Phase-D UserProfileManager.visible_to_user superuser branch.
Code Review — Phase D: IDOR-safe
|
| Category | Count |
|---|---|
| 🔴 Issues to fix | 3 (items 1–3) |
| 🟡 Minor / nits | 3 (items 4–6) |
| ✅ No changes needed | — |
The core abstraction is correct and the migration is systematic and well-tested. Items 1–3 are the ones worth resolving before merge; items 4–6 are lower-priority.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…global_id catches, swap label gates Addresses the three issues + one nit raised in Claude's PR 1693 review. Issue 1 (corpus_mutations.py): DeleteCorpusMutation no longer inherits ``DRFDeletion`` to avoid the fragile exception-for-flow-control pattern where the unauthorized/not-found branch raised ``Corpus.DoesNotExist`` and relied on the parent's behavior surfacing as a GraphQL ``errors`` entry — exactly the IDOR leak Phase D is closing. The deletion logic (lookup, user-lock check, user_can(DELETE), delete) is now inlined on the unified ``ok=False`` envelope, matching every other Phase-D-migrated mutation. ``is_personal`` stays a distinct ``GraphQLError`` because it is observable to anyone who can READ the corpus (exposed on ``CorpusType``), so it does not leak existence. Issue 2 (analysis_mutations.py): wrap ``from_global_id`` in ``DeleteAnalysisMutation`` with ``try/except Exception`` — bare ``binascii.Error`` from malformed base64 was bypassing the ``ValueError``/ ``TypeError`` catch in ``get_for_user_or_none`` and surfacing as raw GraphQL errors instead of the unified ``ok=False`` envelope. Issue 3 (corpus_mutations.py): ``ToggleCorpusMemory`` widens its ``from_global_id`` ``except`` from ``(ValueError, IndexError)`` to bare ``Exception`` for the same ``binascii.Error`` reason — matches the broader catch used at the file's other migrated ``from_global_id`` sites. Nit 4 (corpus_mutations.py): ``StartCorpusFork`` drops the redundant ``user_can(READ)`` check after ``get_for_user_or_none`` — the helper already enforces READ via ``visible_to_user``, so the double-check ran an extra permission evaluation per request without changing the control-flow outcome. Nit 6 (label_mutations.py): ``DeleteMultipleLabelMutation`` swaps the creator gate to run before the ``read_only`` gate. Previously a non-creator who could READ a public read-only label got "Cannot delete read-only labels", revealing both existence and the read-only flag; the new order returns the unified "Label not found" for any non-creator path and only surfaces the read-only message to the creator (who already knows the label exists). Updates two existing tests in ``test_permissioned_querysets.py`` (``test_mutation_permissions``, ``test_mutation_permissions_on_private_object``) that asserted the pre-Phase-D ``data["deleteCorpus"] is None + errors[]`` shape — they now assert the unified ``ok=False`` envelope with no top-level ``errors`` and document the rationale. Nit 5 (UserProfileManager.visible_to_user superuser bypass) confirmed in scope: ``config/graphql/social_queries.py:436, 695`` consume the manager for the leaderboard surface, and widening superuser visibility there is consistent with ``BaseVisibilityManager`` and the broader "superusers see everything" policy.
Code Review — Phase D: IDOR-safe
|
| Category | Assessment |
|---|---|
| Security | Solid — closes the differential-error-text IDOR channel consistently |
| Correctness | Good — no logic regressions found |
| Test coverage | Good — 10 new helper tests + regression on every migrated file |
| Blocking | AddDocumentsToCorpus / RemoveDocumentsFromCorpus inline from_global_id decode could leak error text through the outer except Exception handler |
| Non-blocking | label_mutations raise-as-flow-control inconsistency; DeleteCorpusMutation response-shape change needs frontend verification; negative test case for the READ-precondition regression |
…ssion-centralization-1658 # Conflicts: # CHANGELOG.md
corpus_mutations.py — AddDocumentsToCorpus / RemoveDocumentsFromCorpus: decode global ids up-front so a malformed corpus_id / document_id surfaces as the unified "not found / no permission" envelope instead of echoing raw exception text through the outer ``except Exception as e`` handler (matches every other Phase-D-migrated mutation; closes the residual enumeration channel flagged in the latest PR #1693 review). test_permissioning.py — PermissioningTestCase.__test_only_permissioned_user_can_delete_public_corpus: update the assertion from the pre-Phase-D ``data['deleteCorpus'] is None + errors[]`` shape to the unified ``ok=False + 'not found or you don't have permission'`` envelope that DeleteCorpusMutation now returns after the IDOR fix in commit 77f5a9a.
Code Review — Phase D: IDOR-safe
|
…ssion-centralization-1658
- UpdateExtractMutation: wrap from_global_id for id/corpus_id/fieldset_id in try/except so malformed base64 returns the unified ok=False envelope instead of an unhandled binascii.Error / GraphQL errors entry. - DeleteCorpusMutation: return ok=False for the is_personal rejection instead of raising GraphQLError, so the frontend can pattern-match a single response shape; drop the now-unused GraphQLError import. - label_mutations: replace LabelSet.DoesNotExist control-flow raises in CreateLabelForLabelsetMutation/RemoveLabelsFromLabelsetMutation with explicit early returns, and guard from_global_id parsing. - StartCorpusFork: use the unified not-found message. - UpdateCorpusDescription: clarify creator-only gate in a comment. - Add tests: get_for_user_or_none raises on a model lacking visible_to_user; deleteCorpus is_personal returns unified envelope.
|
Code Review - Phase D: get_for_user_or_none + IDOR migration Overall this is a well-scoped security hardening PR. The helper is clean, TypeVar generics are correct, the fail-loud guardrail for non-permissioned models is a nice touch, and the docstring cross-referencing CorpusObjsService sets the right boundary so the two IDOR-safe surfaces do not drift. Issues
Observations
Positives
|
…ssion-centralization-1658
StartCorpusFork and DeleteCorpusMutation now return unified IDOR-safe ok=False envelopes instead of the prior literals / GraphQLError, so three pre-existing tests asserted stale behavior: - test_cannot_fork_private_corpus_without_permission - test_same_error_for_nonexistent_corpus - test_cannot_delete_personal_corpus Updated their expectations to the unified message / envelope. Also fixed handleDeleteCorpus in Corpuses.tsx, which fired a success toast on any resolved promise — a rejected delete (e.g. personal corpus) now resolves with ok=False and previously showed a false success.
- ReEmbedCorpus returned "Invalid corpus ID" on from_global_id parse failure while every other migrated site returns the unified "Corpus not found"; aligned it. - CreateAgentConfigurationMutation and AwardBadgeMutation decoded corpus_id inside the outer try, so a malformed id bubbled to the generic catch instead of the corpus-specific envelope; wrapped each decode in its own pre-guarded try. - Documented the UserProfileManager.visible_to_user superuser bypass in the permissioning guide so it is not inadvertently reverted.
Phase D IDOR ReviewSolid security refactor overall. The A few things worth a look before merging: 1.
|
…ssion-centralization-1658
…1658) Phase D IDOR review follow-up. get_for_user_or_none catches malformed-pk ValueError/TypeError after the ORM call, but cannot help when from_global_id itself raises (binascii.Error on malformed base64) before the helper runs — the outer mutation except-handler then returned a generic message instead of the unified IDOR-safe envelope. - Wrap from_global_id in agent_mutations.py (Update/Delete AgentConfiguration) and conversation_mutations.py (CreateThread corpus + document arms, CreateThreadMessage, ReplyToMessage, DeleteConversation, DeleteMessage) so a malformed id returns the same not-found/no-permission response. - Unify CreateAgentConfigurationMutation's malformed-id and not-found branches to emit the identical string. - AddDocumentsToCorpus / RemoveDocumentsFromCorpus decode the corpus id and the document ids in separate guards so a malformed document id no longer returns a misleading corpus-scoped error message. - Add docScreenshot to CorpusesView.ct.tsx (covers the Corpuses view touched by the handleDeleteCorpus toast fix). - CHANGELOG: document the from_global_id pre-guard hardening.
Code Review — Phase D: IDOR-safe
|
…ssion-centralization-1658 # Conflicts: # CHANGELOG.md
PR #1693 reworked handleDeleteCorpus to branch on the resolved DeleteCorpusMutation envelope (ok=true -> success toast, ok=false -> error toast) instead of always toasting success. The frontend patch had no test covering the new branches, so codecov/patch/Frontend failed. Adds two CorpusesView component tests that seed the delete-confirmation modal via a new initialDeletingCorpus wrapper prop, click "Yes", and exercise the ok=true and ok=false paths of handleDeleteCorpus. The wrapper prop lets the test reach the confirmation flow without driving the corpus-card context menu.
Code Review — Phase D: IDOR-safe
|
The latest review found from_global_id decodes that were still unguarded, so a malformed base64 id fell through to an outer except and produced a leaked decode error instead of the unified IDOR-safe envelope: - corpus_mutations.py: StartCorpusFork, CreateCorpusAction, AddTemplateToCorpus (both corpus and template ids), and the UpdateCorpusMutation preferred-embedder pre-check now wrap from_global_id and return the same not-found / no-permission message as a missing or hidden corpus. - badge_mutations.py: AwardBadgeMutation now guards the badge_id and user_id decodes, matching the already-guarded corpus_id arm. Also reword an unprofessional comment in analysis_mutations.py flagged by the review.
Code Review — Phase D: IDOR-safe
|
Closes #1658.
Summary
Phase D of the permission-centralization plan. Adds a generic IDOR-safe lookup helper and migrates non-corpus-scoped
Model.objects.get(pk=X) + permission_checkmutation sites onto it so "pk doesn't exist" and "pk exists but caller can't READ it" return the same unified response. Closes the differential-error-text enumeration channel called out in CLAUDE.md's "IDOR Prevention" section.What landed
get_for_user_or_none(model_cls, pk, user)inopencontractserver/utils/permissioning.py. Generic over the model TypeVar so callers preserve concrete attribute typing through the gate. CatchesValueError/TypeErrorfrom garbage pk inputs so malformed GraphQL ids can't bubble as 500s. Cross-referencesCorpusObjsServicein the docstring — corpus-scoped doc lookups continue to route through PR Refactor: Split DocumentService and CorpusObjsService for cleaner architecture #1685's service so the two IDOR-safe surfaces don't drift.opencontractserver/tests/permissioning/test_get_for_user_or_none.pypin every branch (creator / public / missing pk / inaccessible pk / explicit grant / superuser / anonymous-private / anonymous-public / garbage pk / None pk).extract_mutations.py(UpdateExtractMutation,CreateExtractIteration),analysis_mutations.py(DeleteAnalysisMutation— previously raised three different exception types),agent_mutations.py(Create/Update/DeleteAgentConfiguration),corpus_mutations.py(SetCorpusVisibility,UpdateCorpusMutationembedder pre-check,UpdateCorpusDescription,DeleteCorpusMutationis_personalpre-check,StartCorpusFork,ReEmbedCorpus,CreateCorpusAction,AddTemplateToCorpus,ToggleCorpusMemory,AddDocumentsToCorpus,RemoveDocumentsFromCorpus),badge_mutations.py(AwardBadgeMutationBadge / Corpus / User-recipient),label_mutations.py(DeleteMultipleLabelMutation,CreateLabelForLabelsetMutation,RemoveLabelsFromLabelsetMutation),conversation_mutations.py(5 sites),document_mutations.py(RetryDocumentProcessing),document_relationship_mutations.py(CreateDocumentRelationship+UpdateDocumentRelationship).Model.objects.get(pk=...)and then checked the higher permission directly. After Phase D every migrated mutation routes the lookup throughvisible_to_userfirst — the caller must be able to see the row before any non-READ check fires. Three pinning tests were updated to grant[READ, PERMISSION]/[READ, UPDATE]instead of the higher permission alone (test_user_with_permission_perm_can_change_visibility,test_non_owner_with_explicit_update_permission_can_create,test_remove_labels_allows_non_owner_with_explicit_update_permission).test_invalid_id_format_returns_errorupdated to assert the unifiedok=Falseresponse now that the helper swallows malformed-pkValueErrors instead of letting them bubble as GraphQLerrors.UserProfileManager.visible_to_user(opencontractserver/users/models.py:51). MirrorsBaseVisibilityManager's superuser branch so admin / moderation surfaces (badge awarding) can route the recipient lookup throughget_for_user_or_noneand still reach private-profile users — without weakening the IDOR-safe pattern for non-superuser callers.### Security.Test plan
opencontractserver/tests/permissioning/test_get_for_user_or_none.py— 10 tests pass (helper contract).permissioning/+ every affectedtest_*.py: 557 passed, 1 skipped, 0 failed (~11 min serial).Out of scope
.visible_to_user(...).annotation_mutations.py— already usesvisible_to_user(user).get(pk=X)throughout; no bare-get IDOR risk and the surface is large enough to deserve its own pass.UpdateMessageMutation— usesselect_relatedthat doesn't compose with the helper's.filter(pk=pk).first()chain; the site is already IDOR-safe viavisible_to_user.