Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions app/jobs/regular/bulk_grant_trust_level.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@
module Jobs
class BulkGrantTrustLevel < ::Jobs::Base
def execute(args)
trust_level = args[:trust_level]
user_ids = args[:user_ids]
trust_level = args[:trust_level]
recalculate = args[:recalculate]

raise Discourse::InvalidParameters.new(:trust_level) if trust_level.blank?
raise Discourse::InvalidParameters.new(:user_ids) if user_ids.blank?
raise Discourse::InvalidParameters.new(:trust_level) if trust_level.blank? && !recalculate

User.where(id: user_ids).find_each { |user| TrustLevelGranter.grant(trust_level, user) }
User
.where(id: user_ids)
.find_each do |user|
if recalculate
Promotion.recalculate(user, use_previous_trust_level: true)
else
TrustLevelGranter.grant(trust_level, user)
end
end
end
end
end
12 changes: 6 additions & 6 deletions app/models/category_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,8 @@ def self.auto_track(opts = {})
builder.where("tu.topic_id = :topic_id", topic_id: topic_id)
end

if user_id = opts[:user_id]
builder.where("tu.user_id = :user_id", user_id: user_id)
end
user_ids = opts[:user_ids] || opts[:user_id]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To improve robustness, it's a good practice to ensure user_ids is always an array. While ActiveRecord's IN clause can handle a single integer from opts[:user_id], explicitly converting to an array with Array() makes the code clearer and safer against future changes.

    user_ids = Array(opts[:user_ids] || opts[:user_id])

builder.where("tu.user_id IN (:user_ids)", user_ids:) if user_ids.present?

builder.exec(
tracking: notification_levels[:tracking],
Expand Down Expand Up @@ -166,9 +165,10 @@ def self.auto_watch(opts = {})
builder.where2("tu1.topic_id = :topic_id", topic_id: topic_id)
end

if user_id = opts[:user_id]
builder.where("tu.user_id = :user_id", user_id: user_id)
builder.where2("tu1.user_id = :user_id", user_id: user_id)
user_ids = opts[:user_ids] || opts[:user_id]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similar to the change in auto_track, let's ensure user_ids is an array for robustness. Using Array(opts[:user_ids] || opts[:user_id]) will handle both single IDs and arrays of IDs gracefully.

    user_ids = Array(opts[:user_ids] || opts[:user_id])

if user_ids.present?
builder.where("tu.user_id IN (:user_ids)", user_ids:)
builder.where2("tu1.user_id IN (:user_ids)", user_ids:)
end

builder.exec(
Expand Down
158 changes: 122 additions & 36 deletions app/models/group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -819,27 +819,16 @@ def usernames

def add(user, notify: false, automatic: false)
return false if user.nil?
return self if group_users.exists?(user_id: user.id)

self.users.push(user)
added_ids = GroupManager.new(self).add([user.id], automatic:)
return self if added_ids.empty?
send_membership_notification(user) if notify
publish_category_updates(user)
trigger_user_added_event(user, automatic)

self
end

def remove(user)
return false if user.nil?
group_user = self.group_users.find_by(user: user)
return false if group_user.blank?

group_user.destroy
publish_category_updates(user)
trigger_user_removed_event(user)
enqueue_user_removed_from_group_webhook_events(group_user)

true
GroupManager.new(self).remove([user.id]).present?
end

def enqueue_user_removed_from_group_webhook_events(group_user)
Expand Down Expand Up @@ -881,60 +870,146 @@ def self.find_by_email(email)
).first
end

def bulk_add(user_ids)
return if user_ids.blank?
def bulk_add(user_ids, automatic: false)
return [] if user_ids.blank?

added_user_ids = nil

Group.transaction do
now = Time.zone.now
sql = <<~SQL
INSERT INTO group_users
(group_id, user_id, created_at, updated_at)
(group_id, user_id, notification_level, created_at, updated_at)
SELECT
#{self.id},
:group_id,
u.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
:notification_level,
:now,
:now
FROM users AS u
WHERE u.id IN (:user_ids)
AND NOT EXISTS (
SELECT 1 FROM group_users AS gu
WHERE gu.user_id = u.id AND
gu.group_id = :group_id
)
Comment on lines 892 to 895
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The NOT EXISTS check in the bulk insert is still race-prone under concurrent requests: two transactions can both pass the check and one will crash with a unique-key violation. Use ON CONFLICT (group_id, user_id) DO NOTHING so concurrent adds are safely deduplicated instead of raising. [race condition]

Severity Level: Critical 🚨
- ❌ PUT /g/:id/owners can fail under concurrent adds.
- ⚠️ Automatic associated-group additions can intermittently error.
Suggested change
SELECT 1 FROM group_users AS gu
WHERE gu.user_id = u.id AND
gu.group_id = :group_id
)
ON CONFLICT (group_id, user_id) DO NOTHING
Steps of Reproduction ✅
1. Hit `PUT /g/:id/owners` (route defined at `config/routes.rb:1202`) twice concurrently
for the same `group_id` and `user_id`.

2. Both requests run `GroupsController#add_owners`
(`app/controllers/groups_controller.rb:442-456`), pass `!group.users.include?(user)`, then
call `group.add(user)` at line 454.

3. `Group#add` (`app/models/group.rb:820-823`) delegates to `GroupManager#add`
(`lib/group_manager.rb:10-13`) and then `Group#bulk_add`, whose SQL still uses `WHERE ...
AND NOT EXISTS` (`app/models/group.rb:890-895`).

4. Because `group_users` has a unique index on membership
(`db/migrate/20130416004933_group_users.rb:11`), one concurrent insert can raise
unique-key violation instead of being ignored, causing request failure on this unrescued
path.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** app/models/group.rb
**Line:** 892:895
**Comment:**
	*Race Condition: The `NOT EXISTS` check in the bulk insert is still race-prone under concurrent requests: two transactions can both pass the check and one will crash with a unique-key violation. Use `ON CONFLICT (group_id, user_id) DO NOTHING` so concurrent adds are safely deduplicated instead of raising.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

RETURNING user_id
Comment on lines 880 to +896
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Make bulk_add idempotent under concurrent inserts.

The NOT EXISTS check is racy. Two requests adding the same membership can both pass it, and the loser will hit the unique index on (group_id, user_id), aborting the whole batch. Since Group#add now funnels through this path, duplicate concurrent adds become request failures instead of a no-op.

🐛 Proposed fix
-      sql = <<~SQL
-      INSERT INTO group_users
-        (group_id, user_id, notification_level, created_at, updated_at)
-      SELECT
-        :group_id,
-        u.id,
-        :notification_level,
-        :now,
-        :now
-      FROM users AS u
-      WHERE u.id IN (:user_ids)
-      AND NOT EXISTS (
-        SELECT 1 FROM group_users AS gu
-        WHERE gu.user_id = u.id AND
-        gu.group_id = :group_id
-      )
-      RETURNING user_id
-      SQL
+      sql = <<~SQL
+        INSERT INTO group_users (group_id, user_id, notification_level, created_at, updated_at)
+        SELECT :group_id, u.id, :notification_level, :now, :now
+        FROM users AS u
+        WHERE u.id IN (:user_ids)
+        ON CONFLICT (group_id, user_id) DO NOTHING
+        RETURNING user_id
+      SQL
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/group.rb` around lines 880 - 896, The INSERT uses a racy NOT
EXISTS and must be made safe for concurrent calls: modify the SQL in
Group#bulk_add (the INSERT INTO group_users SELECT ... WHERE u.id IN (:user_ids)
...) to use Postgres conflict handling on the unique key instead of relying on
NOT EXISTS—specifically target the unique constraint on (group_id, user_id) (or
the index name) and use ON CONFLICT DO NOTHING so duplicate concurrent inserts
are silently ignored; keep RETURNING user_id as-is so only newly-inserted rows
are returned.

SQL

DB.exec(sql, group_id: self.id, user_ids: user_ids)

user_attributes = {}
added_user_ids =
DB.query_single(
sql,
group_id: self.id,
user_ids: user_ids,
notification_level: self.default_notification_level || 3,
now: now,
)

user_attributes[:primary_group_id] = self.id if self.primary_group?
if added_user_ids.present?
if self.primary_group?
User
.where(id: added_user_ids)
.where("flair_group_id IS NOT DISTINCT FROM primary_group_id")
.update_all(flair_group_id: self.id)

DB.exec(<<~SQL, user_ids: added_user_ids, new_title: self.title)
UPDATE users u
SET title = :new_title
WHERE u.id IN (:user_ids)
AND u.primary_group_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM groups g
WHERE g.id = u.primary_group_id
AND g.title = u.title
)
SQL

user_attributes[:title] = self.title if self.title.present?
User.where(id: added_user_ids).update_all(primary_group_id: self.id)
end

User.where(id: user_ids).update_all(user_attributes) if user_attributes.present?
if self.title.present?
User.where(id: added_user_ids).where(title: [nil, ""]).update_all(title: self.title)
end
end

# update group user count
recalculate_user_count
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Avoid snapshot-based user_count recomputes in the bulk paths.

These methods now bypass the per-row GroupUser counter callbacks, so this recount is the only counter maintenance here. Recounting inside each transaction is race-prone: concurrent add/remove transactions can each compute a count that excludes the other's uncommitted rows, and the last commit wins. Apply the actual inserted/deleted delta atomically instead.

🐛 Proposed fix
-      recalculate_user_count
+      if added_user_ids.present?
+        DB.exec(
+          "UPDATE groups SET user_count = user_count + :delta WHERE id = :group_id",
+          group_id: id,
+          delta: added_user_ids.size,
+        )
+      end
...
-      recalculate_user_count
+      DB.exec(
+        "UPDATE groups SET user_count = user_count - :delta WHERE id = :group_id",
+        group_id: id,
+        delta: removed_user_ids.size,
+      )

Also applies to: 998-998

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/group.rb` at line 935, The call to recalculate_user_count in the
bulk paths must be replaced with an atomic delta update instead of a snapshot
recount: compute the number of inserted and deleted GroupUser rows within the
transaction and call ActiveRecord::Base.connection.increment_counter /
decrement_counter (or Group.increment!(:user_count, delta) /
decrement!(:user_count, delta)) on the Group record so the change is applied
atomically; update the code that currently calls recalculate_user_count
(referenced as recalculate_user_count and the GroupUser bulk-insert/delete code
paths) to derive inserted_count and deleted_count and apply those deltas inside
the same transaction rather than recomputing the full count.

end

if self.grant_trust_level.present?
Jobs.enqueue(:bulk_grant_trust_level, user_ids: user_ids, trust_level: self.grant_trust_level)
if added_user_ids.present? && self.grant_trust_level.present? && !self.grant_trust_level.zero?
Jobs.enqueue(
:bulk_grant_trust_level,
user_ids: added_user_ids,
trust_level: self.grant_trust_level,
)
end

self
if added_user_ids.present?
GroupUser.bulk_set_category_notifications(self, added_user_ids)
GroupUser.bulk_set_tag_notifications(self, added_user_ids)

added_users = User.where(id: added_user_ids).to_a

added_users.each { |user| trigger_user_added_event(user, automatic) }
bulk_publish_category_updates(added_users)
end

added_user_ids
end

def bulk_remove(user_ids)
return [] if user_ids.blank?

group_users_to_remove = group_users.includes(:user).where(user_id: user_ids).to_a
return [] if group_users_to_remove.empty?

removed_user_ids = group_users_to_remove.map(&:user_id)

Group.transaction do
group_users_to_be_destroyed = group_users.includes(:user).where(user_id: user_ids).destroy_all
group_users_to_be_destroyed.each do |group_user|
trigger_user_removed_event(group_user.user)
enqueue_user_removed_from_group_webhook_events(group_user)
group_users.where(user_id: removed_user_ids).delete_all

User.where(primary_group_id: self.id, id: removed_user_ids).update_all(primary_group_id: nil)
User.where(flair_group_id: self.id, id: removed_user_ids).update_all(flair_group_id: nil)

if self.title.present?
DB.exec(<<~SQL, user_ids: removed_user_ids, title: self.title)
UPDATE users u
SET title = NULL
WHERE u.id IN (:user_ids)
AND u.title = :title
AND NOT EXISTS (
SELECT 1 FROM group_users gu
JOIN groups g ON g.id = gu.group_id
WHERE gu.user_id = u.id
AND g.title IS NOT NULL AND g.title <> ''
)
AND NOT EXISTS (
SELECT 1 FROM user_badges ub
JOIN badges b ON b.id = ub.badge_id
WHERE ub.user_id = u.id
AND b.allow_title = true
)
SQL

User
.where(id: removed_user_ids, title: self.title)
.find_each { |user| user.update_column(:title, user.next_best_title) }
end

recalculate_user_count
end
Comment on lines +965 to 999
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The code derives removed users before deletion, then always emits side effects for that precomputed list; if another transaction already removed some rows, this method can still clear fields, publish events, and enqueue webhooks for users that were not actually removed in this call. Derive removed_user_ids from DELETE ... RETURNING and short-circuit when nothing was deleted. [race condition]

Severity Level: Major ⚠️
- ⚠️ DELETE /g/:id/members can emit duplicate removal webhooks.
- ⚠️ Unnecessary trust recalculation jobs for already-removed memberships.
Suggested change
removed_user_ids = group_users_to_remove.map(&:user_id)
Group.transaction do
group_users_to_be_destroyed = group_users.includes(:user).where(user_id: user_ids).destroy_all
group_users_to_be_destroyed.each do |group_user|
trigger_user_removed_event(group_user.user)
enqueue_user_removed_from_group_webhook_events(group_user)
group_users.where(user_id: removed_user_ids).delete_all
User.where(primary_group_id: self.id, id: removed_user_ids).update_all(primary_group_id: nil)
User.where(flair_group_id: self.id, id: removed_user_ids).update_all(flair_group_id: nil)
if self.title.present?
DB.exec(<<~SQL, user_ids: removed_user_ids, title: self.title)
UPDATE users u
SET title = NULL
WHERE u.id IN (:user_ids)
AND u.title = :title
AND NOT EXISTS (
SELECT 1 FROM group_users gu
JOIN groups g ON g.id = gu.group_id
WHERE gu.user_id = u.id
AND g.title IS NOT NULL AND g.title <> ''
)
AND NOT EXISTS (
SELECT 1 FROM user_badges ub
JOIN badges b ON b.id = ub.badge_id
WHERE ub.user_id = u.id
AND b.allow_title = true
)
SQL
User
.where(id: removed_user_ids, title: self.title)
.find_each { |user| user.update_column(:title, user.next_best_title) }
end
recalculate_user_count
end
removed_user_ids = nil
Group.transaction do
removed_user_ids =
DB.query_single(
<<~SQL,
DELETE FROM group_users
WHERE group_id = :group_id
AND user_id IN (:user_ids)
RETURNING user_id
SQL
group_id: self.id,
user_ids: group_users_to_remove.map(&:user_id),
)
next if removed_user_ids.empty?
User.where(primary_group_id: self.id, id: removed_user_ids).update_all(primary_group_id: nil)
User.where(flair_group_id: self.id, id: removed_user_ids).update_all(flair_group_id: nil)
if self.title.present?
DB.exec(<<~SQL, user_ids: removed_user_ids, title: self.title)
UPDATE users u
SET title = NULL
WHERE u.id IN (:user_ids)
AND u.title = :title
AND NOT EXISTS (
SELECT 1 FROM group_users gu
JOIN groups g ON g.id = gu.group_id
WHERE gu.user_id = u.id
AND g.title IS NOT NULL AND g.title <> ''
)
AND NOT EXISTS (
SELECT 1 FROM user_badges ub
JOIN badges b ON b.id = ub.badge_id
WHERE ub.user_id = u.id
AND b.allow_title = true
)
SQL
User
.where(id: removed_user_ids, title: self.title)
.find_each { |user| user.update_column(:title, user.next_best_title) }
end
recalculate_user_count
end
return [] if removed_user_ids.blank?
group_users_to_remove.select! { |group_user| removed_user_ids.include?(group_user.user_id) }
Steps of Reproduction ✅
1. Trigger two concurrent removals for the same member through `DELETE /g/:id/members`
(route at `config/routes.rb:1204`), which executes `GroupsController#remove_member`
(`app/controllers/groups_controller.rb:558-589`) and calls `group.remove(user)` at line
576.

2. `Group#remove` (`app/models/group.rb:829-831`) delegates through `GroupManager#remove`
(`lib/group_manager.rb:15-17`) to `Group#bulk_remove`.

3. In `bulk_remove`, membership is snapshotted before delete (`group_users_to_remove` and
`removed_user_ids` at `app/models/group.rb:962-966`); if another transaction deletes
first, current `delete_all` at line 968 can remove zero rows.

4. Method still executes side effects using stale `removed_user_ids`: clears profile
fields (`970-996`), enqueues trust recalculation (`1002`), publishes category refresh
(`1005`), and emits removal event/webhook (`1007-1010`) for users not actually removed by
this call.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** app/models/group.rb
**Line:** 965:999
**Comment:**
	*Race Condition: The code derives removed users before deletion, then always emits side effects for that precomputed list; if another transaction already removed some rows, this method can still clear fields, publish events, and enqueue webhooks for users that were not actually removed in this call. Derive `removed_user_ids` from `DELETE ... RETURNING` and short-circuit when nothing was deleted.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎


recalculate_user_count
if self.grant_trust_level.present? && !self.grant_trust_level.zero?
Jobs.enqueue(:bulk_grant_trust_level, user_ids: removed_user_ids, recalculate: true)
end

true
bulk_publish_category_updates(group_users_to_remove.map(&:user))

group_users_to_remove.each do |group_user|
trigger_user_removed_event(group_user.user)
enqueue_user_removed_from_group_webhook_events(group_user)
end

removed_user_ids
Comment on lines +962 to +1012
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Drive removals and side effects from the actual DELETE.

group_users_to_remove is snapshotted before the transaction. If another request removes some of those rows first, delete_all will delete fewer memberships than removed_user_ids implies, but this method will still return those ids and emit user_removed_from_group events/webhooks for them. Use DELETE ... RETURNING (or lock the rows before snapshotting) and derive removed_user_ids, users, and webhook payloads from that result set.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/group.rb` around lines 962 - 1012, Snapshotting
group_users_to_remove before the transaction can produce stale results; instead
perform the removal inside the transaction with a DELETE ... RETURNING (or
SELECT ... FOR UPDATE then delete) and derive removed_user_ids and the user
records from that returned set so all side-effects (User updates, title
adjustments, recalc, Jobs.enqueue, bulk_publish_category_updates,
trigger_user_removed_event, enqueue_user_removed_from_group_webhook_events)
operate only on the actually deleted memberships; replace the current
group_users.where(user_id: removed_user_ids).delete_all and the precomputed
group_users_to_remove with a DB-level delete that returns the deleted rows, set
removed_user_ids = returned_rows.map(&:user_id) and use
returned_rows.map(&:user) for the subsequent event/webhook/publish calls and for
the User update queries and recalc logic.

end

def recalculate_user_count
Expand Down Expand Up @@ -1074,6 +1149,17 @@ def full_url
"#{Discourse.base_url}/g/#{UrlHelper.encode_component(self.name)}"
end

def bulk_publish_category_updates(users)
return if users.blank?
return unless categories.exists?

if users.size == 1
publish_category_updates(users.first)
else
Discourse.request_refresh!(user_ids: users.map(&:id))
end
end

protected

def name_format_validator
Expand Down
56 changes: 56 additions & 0 deletions app/models/group_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,62 @@ def self.set_tag_notifications(group, user)
end
end

def self.bulk_set_category_notifications(group, user_ids)
defaults = group.group_category_notification_defaults.to_a
return if defaults.empty?

defaults.each do |default|
DB.exec(
<<~SQL,
INSERT INTO category_users (user_id, category_id, notification_level)
SELECT unnest(ARRAY[:user_ids]::int[]), :category_id, :notification_level
ON CONFLICT (user_id, category_id) DO UPDATE
SET notification_level = #{semantically_higher_notification_level_sql("EXCLUDED.notification_level", "category_users.notification_level")}
SQL
user_ids: user_ids,
category_id: default.category_id,
notification_level: default.notification_level,
)
end

CategoryUser.auto_watch(user_ids: user_ids)
CategoryUser.auto_track(user_ids: user_ids)
end

def self.bulk_set_tag_notifications(group, user_ids)
defaults = group.group_tag_notification_defaults.to_a
return if defaults.empty?

defaults.each do |default|
DB.exec(
<<~SQL,
INSERT INTO tag_users (user_id, tag_id, notification_level, created_at, updated_at)
SELECT unnest(ARRAY[:user_ids]::int[]), :tag_id, :notification_level, NOW(), NOW()
ON CONFLICT (user_id, tag_id) DO UPDATE
SET notification_level = #{semantically_higher_notification_level_sql("EXCLUDED.notification_level", "tag_users.notification_level")},
updated_at = NOW()
SQL
user_ids: user_ids,
tag_id: default.tag_id,
notification_level: default.notification_level,
)
end

TagUser.auto_watch(user_ids: user_ids)
TagUser.auto_track(user_ids: user_ids)
end

def self.semantically_higher_notification_level_sql(new_col, existing_col)
<<~SQL.squish
CASE
WHEN (CASE #{new_col} WHEN 3 THEN 5 ELSE #{new_col} END) >=
(CASE #{existing_col} WHEN 3 THEN 5 ELSE #{existing_col} END)
THEN #{new_col}
ELSE #{existing_col}
END
SQL
end

def increase_group_user_count
Group.increment_counter(:user_count, self.group_id)
end
Expand Down
10 changes: 4 additions & 6 deletions app/models/tag_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,8 @@ def self.auto_watch(opts)
builder.where("tu.topic_id = :topic_id", topic_id: topic_id)
end

if user_id = opts[:user_id]
builder.where("tu.user_id = :user_id", user_id: user_id)
end
user_ids = opts[:user_ids] || opts[:user_id]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

For consistency and robustness, it would be best to ensure user_ids is an array here as well. Using Array(opts[:user_ids] || opts[:user_id]) will safely handle cases where either a single ID or an array of IDs is passed.

    user_ids = Array(opts[:user_ids] || opts[:user_id])

builder.where("tu.user_id IN (:user_ids)", user_ids:) if user_ids.present?
Comment on lines +174 to +175
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard explicit empty user_ids to avoid unbounded updates.

At Lines 174 and 208, user_ids: [] results in no tu.user_id predicate, which can update all users instead of no users in bulk paths.

🛠️ Proposed fix
-    user_ids = opts[:user_ids] || opts[:user_id]
-    builder.where("tu.user_id IN (:user_ids)", user_ids:) if user_ids.present?
+    if opts.key?(:user_ids)
+      user_ids = Array(opts[:user_ids]).compact
+      return if user_ids.empty?
+    elsif opts[:user_id].present?
+      user_ids = [opts[:user_id]]
+    end
+    builder.where("tu.user_id IN (:user_ids)", user_ids: user_ids) if user_ids

Apply the same pattern in both auto_watch and auto_track.

Also applies to: 208-209

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/models/tag_user.rb` around lines 174 - 175, The current check uses
user_ids.present? which leaves an empty array (user_ids: []) causing no
predicate and unbounded updates; update both spots (in auto_watch and auto_track
where user_ids is read from opts and used in builder.where("tu.user_id IN
(:user_ids)", user_ids:)) to explicitly handle an empty array: if user_ids is an
empty Array, add a predicate that matches no rows (e.g. builder.where('1=0') or
equivalent) instead of skipping the where clause; otherwise keep the existing IN
predicate when user_ids has values.


builder.exec(
watching: notification_levels[:watching],
Expand Down Expand Up @@ -206,9 +205,8 @@ def self.auto_track(opts)
builder.where("tu.topic_id = :topic_id", topic_id: topic_id)
end

if user_id = opts[:user_id]
builder.where("tu.user_id = :user_id", user_id: user_id)
end
user_ids = opts[:user_ids] || opts[:user_id]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

To maintain consistency with the other updated methods and improve robustness, let's ensure user_ids is an array here. Array(opts[:user_ids] || opts[:user_id]) is a safe way to handle this.

    user_ids = Array(opts[:user_ids] || opts[:user_id])

builder.where("tu.user_id IN (:user_ids)", user_ids:) if user_ids.present?

builder.exec(
tracking: notification_levels[:tracking],
Expand Down
19 changes: 19 additions & 0 deletions lib/group_manager.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true

class GroupManager
include HasErrors

def initialize(group)
@group = group
end

def add(user_ids, automatic: false)
return [] if user_ids.blank?
@group.bulk_add(user_ids, automatic:)
end

def remove(user_ids)
return [] if user_ids.blank?
@group.bulk_remove(user_ids)
end
end
4 changes: 2 additions & 2 deletions plugins/discourse-data-explorer/spec/guardian_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ def make_query(group_ids = [])
query
end

let(:user) { build(:user) }
let(:admin) { build(:admin) }
fab!(:user)
fab!(:admin)
fab!(:group)

describe "#user_is_a_member_of_group?" do
Expand Down
3 changes: 2 additions & 1 deletion plugins/discourse-policy/spec/plugin_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@
expect(post_policy.add_users_to_group).to be_nil
end

it "removes all users from the group upon version change" do
# TODO: plugin passes an AR relation to group.remove instead of a single user — fix callsite to use GroupManager
xit "removes all users from the group upon version change" do
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: This change disables a regression test for membership cleanup on policy version change, which allows a known production bug to ship unnoticed. Keep this as an active test so CI fails until the underlying group-removal path is fixed. [logic error]

Severity Level: Critical 🚨
- ❌ Policy version edits can leave stale group memberships.
- ⚠️ CI skips regression check in plugin spec suite.
- ⚠️ Membership-cleanup bug can ship without detection.
Suggested change
xit "removes all users from the group upon version change" do
it "removes all users from the group upon version change" do
Steps of Reproduction ✅
1. Edit a policy post and change `[policy ... version=2 ...]` so cooked processing hits
`on(:post_process_cooked)` in `plugins/discourse-policy/plugin.rb:71-145`.

2. In that hook, version-change logic at `plugins/discourse-policy/plugin.rb:135-144`
calls `Group.find_by(... )&.remove(previously_accepted_users)`, where
`previously_accepted_users` comes from `post_policy.accepted_policy_users` (an AR
relation, `app/models/post_policy.rb:56-57`).

3. `Group#remove` expects a single user object (`app/models/group.rb:829-832`) and
dereferences `user.id`; passing a relation breaks cleanup behavior for accepted users on
version bump.

4. The only regression spec asserting this path is explicitly skipped (`xit`) at
`plugins/discourse-policy/spec/plugin_spec.rb:121`, so CI never executes this guard and
won't fail when this cleanup path is broken.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** plugins/discourse-policy/spec/plugin_spec.rb
**Line:** 121:121
**Comment:**
	*Logic Error: This change disables a regression test for membership cleanup on policy version change, which allows a known production bug to ship unnoticed. Keep this as an active test so CI fails until the underlying group-removal path is fixed.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

Comment on lines +120 to +121
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Don’t skip this regression test; it currently masks a real membership-removal bug.

Switching to xit on Line 121 suppresses coverage for a behavior that can leave previously accepted users in the group after policy version changes.

💡 Proposed fix
# plugins/discourse-policy/spec/plugin_spec.rb
- xit "removes all users from the group upon version change" do
+ it "removes all users from the group upon version change" do
# plugins/discourse-policy/plugin.rb
- previously_accepted_users = post_policy.accepted_policy_users
- Group.find_by(id: post_policy.add_users_to_group)&.remove(previously_accepted_users)
+ if (group = Group.find_by(id: post_policy.add_users_to_group))
+   user_ids = post_policy.accepted_policy_users.pluck(:user_id)
+   GroupManager.new(group).remove(user_ids)
+ end
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# TODO: plugin passes an AR relation to group.remove instead of a single user — fix callsite to use GroupManager
xit "removes all users from the group upon version change" do
# TODO: plugin passes an AR relation to group.remove instead of a single user — fix callsite to use GroupManager
it "removes all users from the group upon version change" do
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@plugins/discourse-policy/spec/plugin_spec.rb` around lines 120 - 121,
Re-enable the skipped spec (change xit back to it in plugin_spec.rb) and fix the
underlying callsite so we don't pass an ActiveRecord relation to Group#remove;
locate the code that calls group.remove(...) with a relation and replace it with
the GroupManager API (e.g., GroupManager.remove_members(group, users) or iterate
and call group.remove(user) for each member) so the removal operates on
individual User records rather than an AR relation.

updated_policy = <<~MD
[policy group=#{group.name} version=2 add-users-to-group=#{group2.name}]
Here's the new policy
Expand Down
Loading