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_memberships feature 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 use project_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 IN list. 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 ms

gitlab-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" = 278964
Nested 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 ms

Authz::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 ms

gitlab-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" = 278964
Nested 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 ms

Authz::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_id
Nested 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 ms

gitlab-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_id
Nested 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 ms

References

Screenshots or screen recordings

Not applicable, no UI changes.

How to set up and validate locally

  1. Open a Rails console.
  2. Run ids = Project.limit(5).pluck(:id).
  3. Run Authz::ProjectGrantSources.new.project_rows(ids) and check each project's namespace is returned.
  4. Run Authz::ProjectGrantSources.new.project_shares(ids) to see project shares.
  5. 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.

Edited by Ian Anderson

Merge request reports

Loading
Loading