Skip to content

fix(policy): reject implicit m2m join table deletes when a participant side is not updatable - #2781

Merged
ymc9 merged 2 commits into
devfrom
fix/issue-2752
Aug 3, 2026
Merged

fix(policy): reject implicit m2m join table deletes when a participant side is not updatable#2781
ymc9 merged 2 commits into
devfrom
fix/issue-2752

Conversation

@ymc9

@ymc9 ymc9 commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Deleting rows from an implicit many-to-many join table (e.g. via tags: { set: [] } or tags: { disconnect: ... }) was silently filtered down to zero rows when the caller lacked update permission on a participant model — the operation reported success without actually disconnecting anything.

This PR adds a pre-delete check in the policy handler: when a delete targets an implicit m2m join table, the participant sides whose fk columns are constrained to literal values in the where clause are checked upfront for "update" permission, and the operation is rejected with a policy error if any side fails. This mirrors the existing pre-create enforcement for join-table inserts.

Fixes #2752

Changes

  • PolicyHandler.preDeleteCheck: new pre-mutation check for implicit m2m join-table deletes
  • PolicyHandler.extractEqualityValue: helper to extract the literal value a column is constrained to by a top-level conjunction of the where clause
  • Regression tests covering empty set: [] and disconnect for both denied and permitted callers

Testing

  • New regression test tests/regression/test/issue-2752.test.ts (2 tests, passing)
  • Existing policy e2e suites pass: connect-disconnect, relation-many-to-many-filter, crud/update (36 tests)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved authorization checks for updates and deletions involving many-to-many relationships.
    • Prevented unauthorized users from modifying or removing relationship links.
    • Ensured rejected operations leave existing relationships unchanged.
    • Authorized owners can continue to clear relationships as expected.
    • Corrected the access error reported when creating unauthorized many-to-many relationships.
  • Tests

    • Added regression coverage for setting, clearing, and disconnecting relationship links under different authorization conditions.

…t side is not updatable

Deleting rows from an implicit many-to-many join table (e.g. via `set: []`
or `disconnect`) was silently filtered down to zero rows when the caller
lacked update permission on a participant model, reporting success without
doing anything. Now such deletes are rejected upfront, consistent with how
join-table inserts are handled.

Fixes #2752

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 46f16a7f-0ee6-4346-a96e-2eddf01c1d84

📥 Commits

Reviewing files that changed from the base of the PR and between a20dc66 and c52ca16.

📒 Files selected for processing (1)
  • packages/plugins/policy/src/policy-handler.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/plugins/policy/src/policy-handler.ts

📝 Walkthrough

Walkthrough

The policy handler now validates implicit many-to-many participants before delete execution. It extracts constrained join-table values, evaluates participant update policies, and rejects unauthorized operations. Regression tests cover non-empty and empty set operations and disconnect.

Changes

Implicit many-to-many policy enforcement

Layer / File(s) Summary
Pre-delete participant validation
packages/plugins/policy/src/policy-handler.ts
The delete path invokes preDeleteCheck. The handler extracts supported equality constraints from conjunctions and checks update policies for targeted relation participants. Many-to-many create rejection now uses NO_ACCESS.
Regression coverage
tests/regression/test/issue-2752.test.ts
Tests verify rejection of unauthorized set and disconnect operations, preservation of existing links, and successful clearing by an authorized owner.

Estimated code review effort: 4 (Complex) | ~35 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The correction of the many-to-many create rejection reason to NO_ACCESS is not required by issue #2752. Move the create rejection-reason correction to a separate pull request or link an issue that requires this behavior.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: rejecting unauthorized implicit many-to-many join-table deletes.
Linked Issues check ✅ Passed The implementation and regression tests address issue #2752 by rejecting unauthorized empty set and disconnect operations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-2752

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
tests/regression/test/issue-2752.test.ts (1)

73-123: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a positive-path assertion for authorized disconnect.

This test only verifies that an unauthorized caller is rejected. Test 1 verifies both denial and success paths for set: []. Add a similar assertion here: after the rejection check, have the owner (or another caller with update permission on both Profile and Tag) successfully disconnect the tag, then confirm the link is removed. This confirms preDeleteCheck does not overreject legitimate disconnect operations.

✅ Suggested addition
         // links intact
         await expect(
             rawDb.profile.findUniqueOrThrow({ where: { id: profile.id }, include: { tags: true } }),
         ).resolves.toMatchObject({ tags: [expect.objectContaining({ id: 'a' })] });
+
+        // the owner can do it
+        const asOwner = db.$setAuth({ id: owner.id });
+        await expect(
+            asOwner.profile.update({ where: { id: profile.id }, data: { tags: { disconnect: { id: 'a' } } } }),
+        ).toResolveTruthy();
+        await expect(
+            rawDb.profile.findUniqueOrThrow({ where: { id: profile.id }, include: { tags: true } }),
+        ).resolves.toMatchObject({ tags: [] });
     });
As per path instructions, `tests/regression/test/issue-*.test.ts` files should live in `tests/regression/test/` with filename format `issue-{number}.test.ts`; this file already satisfies that pattern.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/regression/test/issue-2752.test.ts` around lines 73 - 123, Extend the
test around the unauthorized asOther.profile.update disconnect assertion by
performing an authorized disconnect as the owner (or another caller permitted to
update both Profile and Tag), then assert through
rawDb.profile.findUniqueOrThrow with tags included that the tag link is removed.
Keep the existing rejection and link-intact checks before the authorized
operation.

Source: Path instructions

packages/plugins/policy/src/policy-handler.ts (1)

254-300: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate per-side policy-check subquery logic.

The per-side subquery construction here (select participant model, filter by id equality, project the update policy filter as a boolean column) closely duplicates the logic in enforcePreCreatePolicyForManyToManyJoinTable (lines 966-978). Consider extracting a shared private helper, for example buildParticipantUpdatableSelection(model, idField, value, alias), that both preDeleteCheck and enforcePreCreatePolicyForManyToManyJoinTable can call. This reduces the risk of the two code paths silently diverging over time.

♻️ Example shared helper sketch
+    private buildParticipantUpdatableSelection(model: string, idField: string, value: unknown, alias: string) {
+        return SelectionNode.create(
+            AliasNode.create(
+                this.eb
+                    .selectFrom(model)
+                    .where(this.eb(this.eb.ref(`${model}.${idField}`), '=', value))
+                    .select(() => new ExpressionWrapper(this.buildPolicyFilter(model, undefined, 'update')).as('_'))
+                    .toOperationNode(),
+                IdentifierNode.create(alias),
+            ),
+        );
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/plugins/policy/src/policy-handler.ts` around lines 254 - 300,
Extract the duplicated participant updatable-selection construction from
preDeleteCheck and enforcePreCreatePolicyForManyToManyJoinTable into a shared
private helper such as buildParticipantUpdatableSelection(model, idField, value,
alias). Have both call sites use the helper while preserving the existing
model/id equality filter, update policy projection, and alias behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/plugins/policy/src/policy-handler.ts`:
- Around line 273-299: Distinguish nonexistent many-to-many participants from
failed update-policy checks in the result validation loop after proceed. Use the
participant identity fields in each side to detect when the referenced row is
absent and report a row-not-found outcome (or allow the documented harmless
no-op), while retaining the existing NO_ACCESS error only when the participant
exists but its update policy evaluates false.

---

Nitpick comments:
In `@packages/plugins/policy/src/policy-handler.ts`:
- Around line 254-300: Extract the duplicated participant updatable-selection
construction from preDeleteCheck and
enforcePreCreatePolicyForManyToManyJoinTable into a shared private helper such
as buildParticipantUpdatableSelection(model, idField, value, alias). Have both
call sites use the helper while preserving the existing model/id equality
filter, update policy projection, and alias behavior.

In `@tests/regression/test/issue-2752.test.ts`:
- Around line 73-123: Extend the test around the unauthorized
asOther.profile.update disconnect assertion by performing an authorized
disconnect as the owner (or another caller permitted to update both Profile and
Tag), then assert through rawDb.profile.findUniqueOrThrow with tags included
that the tag link is removed. Keep the existing rejection and link-intact checks
before the authorized operation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 78b06225-b6b5-4b2e-8975-d53eae4ed401

📥 Commits

Reviewing files that changed from the base of the PR and between 88cf173 and a20dc66.

📒 Files selected for processing (2)
  • packages/plugins/policy/src/policy-handler.ts
  • tests/regression/test/issue-2752.test.ts

Comment thread packages/plugins/policy/src/policy-handler.ts
…ejection

The first participant side incorrectly reported CANNOT_READ_BACK; both sides
now consistently report NO_ACCESS like the delete path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ymc9
ymc9 merged commit 8cae2fc into dev Aug 3, 2026
8 checks passed
@ymc9
ymc9 deleted the fix/issue-2752 branch August 3, 2026 05:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

v3 policy: update with only an empty implicit-M2M set: [] succeeds (silent no-op) for a caller denied by the model's update policy

1 participant