Add loader for the project data that decides access
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. - Project roles currently come from
project_authorizations, which keeps only the highest role per user and project and lags behind membership changes. This stack reads roles directly from memberships and shares instead. - This MR adds the first building block: loading the project data that decides who can access a project, regardless of user (each project's namespace, project shares, and group shares into the groups involved).
- The result is cached for the request, so every user and preloader checking the same projects reuses it.
- Nothing calls it yet. It is split out so database reviewers only see the query code.
- Group shares use a join on an unnested array of group IDs instead of a huge
INlist. A list of 108,218 IDs took 1.75 s to plan, versus 0.14 ms with unnest.
Query plans
Captured on a production database clone (Postgres 17) with EXPLAIN (ANALYZE, BUFFERS), warm. Long ID arrays are shortened.
These queries don't depend on the user, so each one is shown for a typical set of projects and for gitlab-org/gitlab, a large project (83 project shares, 130 groups to check for group shares).
Authz::ProjectGrantSources#project_rows
Each project's namespace: type, owner, traversal IDs and share lock.
20 projects, from a typical user
SELECT "projects"."id", "namespaces"."type", "namespaces"."owner_id", "namespaces"."traversal_ids", "namespaces"."share_with_group_lock" FROM "projects" INNER JOIN "namespaces" ON "namespaces"."id" = "projects"."namespace_id" WHERE "projects"."id" IN (3810183, 14620845, 9264743, 4261336, 2700836, 4450053, 8382450, 14676935, 13339203, 9183526, 3593291, 6585375, 16079718, 17393294, 14677479, 3647796, 15306598, 7104990, 6336350, 42356399)Nested Loop (cost=1.13..137.80 rows=20 width=43) (actual time=0.022..0.158 rows=20 loops=1)
Buffers: shared hit=200
-> Index Scan using projects_pkey on projects (cost=0.56..66.10 rows=20 width=8) (actual time=0.013..0.112 rows=20 loops=1)
Index Cond: (id = ANY ('{3810183,14620845,9264743,4261336,2700836,4450053,8382450,14676935,13339203,9183526,3593291,6585375,16079718,17393294,14677479,3647796,15306598,7104990,6336350,42356399}'::integer[]))
Buffers: shared hit=100
-> Index Scan using namespaces_pkey on namespaces (cost=0.57..3.58 rows=1 width=43) (actual time=0.002..0.002 rows=1 loops=20)
Index Cond: (id = projects.namespace_id)
Buffers: shared hit=100
Planning:
Buffers: shared hit=22
Planning Time: 0.479 ms
Execution Time: 0.178 msgitlab-org/gitlab
SELECT "projects"."id", "namespaces"."type", "namespaces"."owner_id", "namespaces"."traversal_ids", "namespaces"."share_with_group_lock" FROM "projects" INNER JOIN "namespaces" ON "namespaces"."id" = "projects"."namespace_id" WHERE "projects"."id" = 278964Nested Loop (cost=1.13..7.17 rows=1 width=43) (actual time=0.016..0.017 rows=1 loops=1)
Buffers: shared hit=10
-> Index Scan using idx_projects_on_repository_storage_last_repository_updated_at on projects (cost=0.56..3.58 rows=1 width=8) (actual time=0.009..0.010 rows=1 loops=1)
Index Cond: (id = 278964)
Buffers: shared hit=5
-> Index Scan using namespaces_pkey on namespaces (cost=0.57..3.58 rows=1 width=43) (actual time=0.005..0.005 rows=1 loops=1)
Index Cond: (id = projects.namespace_id)
Buffers: shared hit=5
Planning:
Buffers: shared hit=22
Planning Time: 0.417 ms
Execution Time: 0.035 msAuthz::ProjectGrantSources#project_shares
Groups each project is shared with, and their traversal IDs.
20 projects, from a typical user
SELECT "project_group_links"."project_id", "project_group_links"."group_access", "project_group_links"."member_role_id", "namespaces"."traversal_ids" FROM "project_group_links" INNER JOIN "namespaces" ON "namespaces"."id" = "project_group_links"."group_id" AND "namespaces"."type" = 'Group' WHERE "project_group_links"."project_id" IN (3810183, 14620845, 9264743, 4261336, 2700836, 4450053, 8382450, 14676935, 13339203, 9183526, 3593291, 6585375, 16079718, 17393294, 14677479, 3647796, 15306598, 7104990, 6336350, 42356399)Nested Loop (cost=0.99..276.03 rows=7 width=44) (actual time=0.059..0.059 rows=0 loops=1)
Buffers: shared hit=57
-> Index Scan using index_project_group_links_on_project_id on project_group_links (cost=0.43..78.72 rows=55 width=20) (actual time=0.059..0.059 rows=0 loops=1)
Index Cond: (project_id = ANY ('{3810183,14620845,9264743,4261336,2700836,4450053,8382450,14676935,13339203,9183526,3593291,6585375,16079718,17393294,14677479,3647796,15306598,7104990,6336350,42356399}'::integer[]))
Buffers: shared hit=57
-> Index Scan using namespaces_pkey on namespaces (cost=0.57..3.59 rows=1 width=32) (never executed)
Index Cond: (id = project_group_links.group_id)
Filter: ((type)::text = 'Group'::text)
Planning:
Buffers: shared hit=21
Planning Time: 0.473 ms
Execution Time: 0.076 msgitlab-org/gitlab
SELECT "project_group_links"."project_id", "project_group_links"."group_access", "project_group_links"."member_role_id", "namespaces"."traversal_ids" FROM "project_group_links" INNER JOIN "namespaces" ON "namespaces"."id" = "project_group_links"."group_id" AND "namespaces"."type" = 'Group' WHERE "project_group_links"."project_id" = 278964Nested Loop (cost=0.99..217.86 rows=6 width=44) (actual time=0.015..0.385 rows=83 loops=1)
Buffers: shared hit=499
-> Index Scan using index_project_group_links_on_project_id on project_group_links (cost=0.43..38.48 rows=50 width=20) (actual time=0.006..0.060 rows=83 loops=1)
Index Cond: (project_id = 278964)
Buffers: shared hit=84
-> Index Scan using namespaces_pkey on namespaces (cost=0.57..3.59 rows=1 width=32) (actual time=0.004..0.004 rows=1 loops=83)
Index Cond: (id = project_group_links.group_id)
Filter: ((type)::text = 'Group'::text)
Buffers: shared hit=415
Planning:
Buffers: shared hit=21
Planning Time: 0.324 ms
Execution Time: 0.405 msAuthz::ProjectGrantSources#group_shares
Group shares into the groups of each project's hierarchy and each invited group's hierarchy.
20 projects, from a typical user
SELECT "group_group_links"."shared_group_id", "group_group_links"."shared_with_group_id", "group_group_links"."group_access", "group_group_links"."member_role_id" FROM "group_group_links" INNER JOIN unnest('{1749956,3612867}'::bigint[]) AS shared_groups(id) ON shared_groups.id = group_group_links.shared_group_idNested Loop (cost=0.42..11.65 rows=6 width=26) (actual time=0.019..0.019 rows=0 loops=1)
Buffers: shared hit=6
-> Function Scan on unnest shared_groups (cost=0.00..0.02 rows=2 width=8) (actual time=0.003..0.004 rows=2 loops=1)
-> Index Scan using index_group_group_links_on_shared_group_and_shared_with_group on group_group_links (cost=0.42..5.78 rows=3 width=26) (actual time=0.007..0.007 rows=0 loops=2)
Index Cond: (shared_group_id = shared_groups.id)
Buffers: shared hit=6
Planning Time: 0.136 ms
Execution Time: 0.036 msgitlab-org/gitlab
SELECT "group_group_links"."shared_group_id", "group_group_links"."shared_with_group_id", "group_group_links"."group_access", "group_group_links"."member_role_id" FROM "group_group_links" INNER JOIN unnest('{130 ids}'::bigint[]) AS shared_groups(id) ON shared_groups.id = group_group_links.shared_group_idNested Loop (cost=0.42..714.99 rows=381 width=26) (actual time=0.018..0.222 rows=22 loops=1)
Buffers: shared hit=412
-> Function Scan on unnest shared_groups (cost=0.00..1.30 rows=130 width=8) (actual time=0.008..0.015 rows=130 loops=1)
-> Index Scan using index_group_group_links_on_shared_group_and_shared_with_group on group_group_links (cost=0.42..5.46 rows=3 width=26) (actual time=0.001..0.001 rows=0 loops=130)
Index Cond: (shared_group_id = shared_groups.id)
Buffers: shared hit=412
Planning Time: 0.119 ms
Execution Time: 0.238 msReferences
- Work item: https://gitlab.com/gitlab-org/gitlab/-/work_items/632453
- 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.
- Run
ids = Project.limit(5).pluck(:id). - Run
Authz::ProjectGrantSources.new.project_rows(ids)and check each project's namespace is returned. - Run
Authz::ProjectGrantSources.new.project_shares(ids)to see project shares. - Run
Authz::ProjectGrantSources.new.group_shares(group_ids)with the group IDs from the previous results to see group shares.
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist.