Loading
Resolve project roles from direct and inherited memberships
What does this MR do and why?
- This is an experiment behind the
resolve_project_access_from_membershipsfeature flag (per user, default off). It only changes direct access checks, where we check one user's access to a specific project. Searches and lists that decide which projects a user can see still useproject_authorizations. - Adds the first half of a resolver that finds every role a user holds on given projects, read directly from memberships: direct project memberships, memberships of the project's group and its ancestors, and personal projects.
- This avoids
project_authorizations, which keeps only the highest role per user and project and lags behind membership changes. - It only loads memberships that can grant access to the projects being checked, so cost doesn't grow with how many memberships a user has elsewhere. It uses the existing members index on
user_id, source_id, source_type. - Shares come in the next MR. Nothing calls this yet.
- Measured on a production clone (whole resolver): about 0-0.6 ms and 1-2 queries per project check for typical users. Users with 21k-108k memberships take 2.5 ms (1.6-1.8 ms today). Results matched
project_authorizationsin every comparison.
Query plans
Captured on a production database clone (Postgres 17) with EXPLAIN (ANALYZE, BUFFERS), warm. Long ID arrays are shortened.
Typical user (11 memberships), 20 projects: memberships
SELECT "members"."source_type", "members"."source_id", "members"."access_level", "members"."member_role_id" FROM "members" INNER JOIN unnest('{1749956,3612867,3810183,14620845,9264743,4261336,2700836,4450053,8382450,14676935,13339203,9183526,3593291,6585375,16079718,17393294,14677479,3647796,15306598,7104990,6336350,42356399}'::bigint[], '{22 types}'::text[]) AS wanted(source_id, source_type) ON wanted.source_id = members.source_id AND wanted.source_type = members.source_type WHERE "members"."user_id" = 502448 AND "members"."requested_at" IS NULL AND "members"."state" = 0Nested Loop (cost=0.57..79.48 rows=1 width=24) (actual time=0.027..0.074 rows=11 loops=1)
Buffers: shared hit=99
-> Function Scan on wanted (cost=0.01..0.23 rows=22 width=40) (actual time=0.007..0.009 rows=22 loops=1)
-> Index Scan using idx_members_on_user_and_source_and_source_type_and_member_role on members (cost=0.57..3.59 rows=1 width=24) (actual time=0.003..0.003 rows=0 loops=22)
Index Cond: ((user_id = 502448) AND (source_id = wanted.source_id) AND ((source_type)::text = wanted.source_type))
Filter: ((requested_at IS NULL) AND (state = 0))
Buffers: shared hit=99
Planning Time: 0.190 ms
Execution Time: 0.093 msUser with 108,218 memberships, gitlab-org/gitlab: memberships
SELECT "members"."source_type", "members"."source_id", "members"."access_level", "members"."member_role_id" FROM "members" INNER JOIN unnest('{147 ids}'::bigint[], '{147 types}'::text[]) AS wanted(source_id, source_type) ON wanted.source_id = members.source_id AND wanted.source_type = members.source_type WHERE "members"."user_id" = 36397453 AND "members"."requested_at" IS NULL AND "members"."state" = 0Nested Loop (cost=0.57..531.04 rows=1 width=24) (actual time=0.255..0.256 rows=0 loops=1)
Buffers: shared hit=588
-> Function Scan on wanted (cost=0.01..1.47 rows=147 width=40) (actual time=0.017..0.031 rows=147 loops=1)
-> Index Scan using idx_members_on_user_and_source_and_source_type_and_member_role on members (cost=0.57..3.59 rows=1 width=24) (actual time=0.001..0.001 rows=0 loops=147)
Index Cond: ((user_id = 36397453) AND (source_id = wanted.source_id) AND ((source_type)::text = wanted.source_type))
Filter: ((requested_at IS NULL) AND (state = 0))
Buffers: shared hit=588
Planning Time: 0.221 ms
Execution Time: 0.278 msMember of gitlab-org/gitlab (147 relevant sources): memberships
SELECT "members"."source_type", "members"."source_id", "members"."access_level", "members"."member_role_id" FROM "members" INNER JOIN unnest('{147 ids}'::bigint[], '{147 types}'::text[]) AS wanted(source_id, source_type) ON wanted.source_id = members.source_id AND wanted.source_type = members.source_type WHERE "members"."user_id" = 1 AND "members"."requested_at" IS NULL AND "members"."state" = 0Hash Join (cost=4.25..116.16 rows=1 width=24) (actual time=0.091..0.345 rows=2 loops=1)
Hash Cond: ((members.source_id = wanted.source_id) AND ((members.source_type)::text = wanted.source_type))
Buffers: shared hit=253
-> Index Scan using idx_members_on_user_and_source_and_source_type_and_member_role on members (cost=0.57..111.84 rows=84 width=24) (actual time=0.018..0.260 rows=257 loops=1)
Index Cond: (user_id = 1)
Filter: ((requested_at IS NULL) AND (state = 0))
Buffers: shared hit=253
-> Hash (cost=1.47..1.47 rows=147 width=40) (actual time=0.055..0.056 rows=147 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 16kB
-> Function Scan on wanted (cost=0.01..1.47 rows=147 width=40) (actual time=0.018..0.034 rows=147 loops=1)
Planning Time: 0.201 ms
Execution Time: 0.367 msReferences
- Work item: https://gitlab.com/gitlab-org/gitlab/-/work_items/632453
- Builds on: !263761 (merged)
- Related bug: https://gitlab.com/gitlab-org/gitlab/-/work_items/578241
Screenshots or screen recordings
Not applicable, no UI changes.
How to set up and validate locally
- Open a Rails console and pick a user and a few projects without shares.
- Run
Authz::UserProjectGrants.new(user.id).for_projects(ids). - Run
Authz::UserProjectGrants.new(user.id).max_access_levels(ids). - Compare with
user.project_authorizations.where(project_id: ids).pluck(:project_id, :access_level). The highest level per project should match.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.
Edited by Ian Anderson