Filter notification recipients by current board access; reconcile stale assignments - #3088
Filter notification recipients by current board access; reconcile stale assignments#3088jeremy wants to merge 2 commits into
Conversation
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
Pull request overview
Filters notification recipients by current board access and removes inaccessible card assignments.
Changes:
- Adds centralized notification access filtering.
- Cleans stale assignments during card moves and access revocation.
- Adds regression coverage for both behaviors.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
app/models/notifier.rb |
Filters recipients by current board access. |
app/models/card/accessible.rb |
Removes inaccessible assignments after moves. |
app/models/board/accessible.rb |
Removes assignments after access revocation. |
test/models/notifier/event_notifier_test.rb |
Tests stale-watcher filtering. |
test/models/card_test.rb |
Tests card assignment cleanup. |
test/models/access_test.rb |
Tests cleanup following access revocation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 72501b3d23
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| accessible_user_ids = source.card.board.accesses.pluck(:user_id).to_set | ||
| recipients.select { |recipient| !recipient.active? || accessible_user_ids.include?(recipient.id) } |
There was a problem hiding this comment.
Make the access check atomic with notification creation
If an Access row is revoked after this pluck snapshot but before Notification.create_or_find_by runs, the recipient remains selected and the notification and its push job are still created after access was lost. This leaves a TOCTOU path for delivering private card/comment content during concurrent revocation; coordinate notification creation with access revocation (for example through locking) or revalidate authorization at delivery rather than relying on this earlier snapshot. Board authorization is explicitly represented by per-user Access records.
AGENTS.md reference: AGENTS.md:L31-L33
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
🤖 Assessed — not acting, by design. This is the async-notification window the operator explicitly accepted on the parent card ("the async window is accepted throughout Basecamp; the operation is complete when the eventual jobs have been performed, not at HTTP response"). Two things bound it: (1) this filter already collapses the pre-existing leak from the seconds-long post-board-move cleanup window down to the microseconds between the pluck and create_or_find_by in a single job; (2) access revocation fires Board::CleanInaccessibleDataJob → clean_inaccessible_data_for, which async-destroys any notification created for the now-inaccessible user. A recipient notified in that residual window was authorized when the comment was created; the revocation is concurrent and self-heals. Coordinating notify with revocation via a lock is the over-serialization AGENTS.md warns against for an accepted async window. Flagged to the operator in case he wants the stronger real-time guarantee.
| notifications_for_user(user).destroy_all | ||
| watches_for(user).destroy_all | ||
| pins_for(user).destroy_all | ||
| assignments_for(user).destroy_all |
There was a problem hiding this comment.
Recheck access before deleting assignments
Because this cleanup runs asynchronously, a user can regain board access after the check on line 44 but before this deletion; they can even receive a new valid assignment during that interval, and this unrestricted query then destroys it. The method already skips cleanup when access was restored before the job began, so deleting assignments when restoration happens moments later is an inconsistent data-loss race; recheck access immediately before deletion or serialize cleanup with access grants.
AGENTS.md reference: AGENTS.md:L6-L9
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
🤖 Assessed — not acting. This deletion follows the exact idiom already used by the four sibling cleanups in this same method (mentions_for_user/notifications_for_user/watches_for/pins_for): guard on accessible_to?(user), then bulk-destroy that user's board data. The regain-access-then-reassign-within-the-window race you describe applies identically to all of them, so it is a property of the method's accepted async design, not something this change introduces. Special-casing a per-record access recheck for assignments alone would be inconsistent with the siblings and would not close the same window on them; if we want that guarantee it belongs as a uniform serialize-cleanup-with-grants change — a separate design decision neither this card nor #3070 raises. Flagged to the operator.
Problem
Two board-move follow-ups spun off from #3070 (H1 #3512076), both in the card access/notification path.
P1 — notification leak via unfiltered
card.watchers(security)card.watchersis watch-scoped, not access-scoped:After a card moves to a board an assignee/watcher can't access,
handle_board_changeonly schedules async cleanup (clean_inaccessible_data_later,remove_inaccessible_notifications_later). In that window the watch row still exists, soNotifier::CommentEventNotifier#recipients(andCardEventNotifier's comment branch, andcard_published's force-includedcard.assignees) select recipients who no longer holdAccessto the card's board. A new comment then pushes a private comment excerpt to them. Pre-existing; independent of #3070 (which turns the permanent over-grant into a transient window, not the origin).P2 — stale
Assignmentafter access loss (correctness)Neither
Card#clean_inaccessible_datanorBoard#clean_inaccessible_data_forreconcilesAssignmentrows. So a user can remain assigned to a card on a board they can't access.Cards::AssignmentsController#createresolves toggles via@board.users.active.find(...), so unassigning that user raisesActiveRecord::RecordNotFound; the stale rows also count towardAssignment::LIMIT. Reachable today via admin access-revocation (Access#destroy→Board#clean_inaccessible_data_for), and via the move path once #3070's grant-skip lands.Fix
P1 — one access gate at the notify point (
Notifier#notify), covering every branch (comment, publish, assignment, mention) and any future notifier, rather than patching eachcard.watchers/assignee call site:The
!recipient.active?clause preserves existing behavior for deactivated users:User#deactivatedestroys theiraccesses, and notifications are still created for them (never pushed — they have no session). The leak class is active users who lost access;card.watchersis alreadyactive-scoped, so this matches the actual exposure. Onall_accessboards every active user holds anAccessrow, so nothing legitimate is filtered.Defense at the read/notify point, independent of the async cleanup — a pure per-send read filter, so no lock/marker/persisted intermediate state and no new TOCTOU or regenerate-race surface. Fails closed (no access row ⇒ active user not notified).
P2 — reconcile stale assignments in both cleanup paths, alongside the existing pins/watches reconciliation:
Card#clean_inaccessible_data(move path):assignments.where.not(assignee_id: accessible_user_ids).in_batches.delete_allBoard#clean_inaccessible_data_for(user)(access-revoke path):assignments_for(user).delete_alldelete_allavoids emitting spuriouscard_unassignedevents for a cleanup.Tests
Notifier::EventNotifierTest— an active watcher who loses board access on a move is not notified when a new comment is posted (stale watch asserted present in the window). Red before the notifier change, green after.CardTest—clean_inaccessible_dataremoves an assignment for a user without board access. Red before, green after.AccessTest— assignments are destroyed when a user's board access is revoked (mirrors the existing watches/pins/mentions coverage). Red before, green after.Deactivated-user notification behavior is unchanged (existing
NotificationDeliveryTestpasses).Verification
Out of scope / notes
Relates to H1 #3512076; follow-up to #3070.