Avoid loading all project members for bulk permission checks

🤖 From agent.

What does this MR do and why?

GitLab often needs to answer "which of these many users can see this project?" — for example, to work out who should get notified about something. To make that check fast, the code used to load the project's entire member list into memory once, so it could answer membership questions without hitting the database again for each user.

That shortcut stopped actually helping a while ago. Permission checks now go through a user's role, and the role lookup queries the database for that user's access level directly, bypassing the preloaded member list entirely. So the check ended up paying twice: it still loaded the whole member list, and it still ran a separate query per user. On a project that inherits membership from a large group, checking access for a handful of people could pull in thousands of user records that were never used.

The fix uses the fact that the access-level lookup already supports being loaded in bulk and cached for the rest of the request. The code paths that check many users against one project now populate that cache with a single query up front, and the membership check reads from the cache instead of preloading members. The member-list preload is removed.

A small related cleanup is included: one permission rule that granted "read project" to members was redundant, since role configuration already grants the same permission. It's been removed, and this was verified by confirming every role from Guest upward still resolves that permission through role configuration.

As a side effect, the membership check is now consistent regardless of how it's invoked. Previously it gave different answers in different call paths for a few special cases — instance administrators in admin mode, organization owners, and internal bots — depending on whether the preloaded list or the cache was used. Now it's consistent. This changes nothing about actual access: all of those actors already had the relevant permission through other rules, which was checked directly.

Measured impact (local GDK, this branch vs. master, bulk-checking 10 users against one project, median of 5 runs after warm-up; 2 of the queries in every count are the benchmark's own setup queries):

Project with 5,013 authorized users:

SQL queries Membership queries Allocated objects Time
master 21 11 126,672 107.97 ms
this branch 11 1 24,364 28.82 ms

Project with 11 authorized users:

SQL queries Membership queries Allocated objects Time
master 18 9 28,967 33.32 ms
this branch 10 1 22,971 27.58 ms

Both versions return exactly the same set of users as able to read the project. The improvement isn't just for large projects — the per-user query pattern is removed in both cases, so even a small project benefits.

One follow-up is intentionally left for later: an EE hook that separately preloads namespace bans for one of these bulk checks is now redundant. It's left in place here to keep this change small and focused.

References

No related issue.

Screenshots or screen recordings

No user-visible interface change.

How to set up and validate locally

  1. Run the relevant spec suites:
    bundle exec rspec spec/models/ability_spec.rb spec/policies/project_policy_spec.rb ee/spec/policies/project_policy_spec.rb spec/policies/packages/policies/project_policy_spec.rb
  2. All 6185 examples should pass (2 pre-existing pending examples are expected).
  3. A new regression test in spec/models/ability_spec.rb asserts that the membership lookup runs exactly once no matter how many users are checked, and that the correct users are returned. It can be run on its own, and checked out against master to confirm it fails there (3 queries instead of 1).

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

🤖 Generated with Claude Code

Merge request reports

Loading
Loading