Allow awarders to see hidden achievements via includeHidden

What does this MR do and why?

The userAchievements GraphQL resolver filtered hidden records (show_on_profile: false) using a strict identity check: only the profile owner could see them when passing includeHidden: true. Users with award_achievement permission were excluded from this visibility, even if they held maintainer or owner access to the achievement's namespace. As a result, awarder automation could not detect existing unaccepted records and would re-award on every run.

This MR extends the resolver with a third branch for awarder callers. When includeHidden: true is passed by a caller who can reach the achievement's namespace via any of the three membership paths (direct maintainer/owner membership, inherited access via a parent group, or group-link access where the caller's group was invited with maintainer+ access), the resolver returns both visible and hidden records for that namespace.

The namespace lookup follows the same approach used by Groups::AcceptingProjectImportsFinder, Groups::AcceptingProjectTransfersFinder, and Groups::AcceptingProjectCreationsFinder, which solve the same three-path membership problem. The UNION query (UserAchievement.from_union) keeps the query plan simple and avoids an N+1. The profile-owner branch and the default (shown-only) branch are unchanged.

Changed files:

  • app/models/achievements/user_achievement.rb: adds include FromUnion to enable the UNION query.
  • app/graphql/resolvers/achievements/user_achievements_for_user_resolver.rb: implements the three-branch resolver logic; the awarder branch resolves namespace access via direct membership, inherited parent-group access, and group-link access.
  • spec/requests/api/graphql/achievements/user_achievements_query_spec.rb: adds specs for the awarder-sees-hidden path (direct, inherited, and group-link membership), cross-namespace isolation, and N+1 safety.

Database queries

The profile query fetches a user's visible achievements via a UNION of two branches. The first branch returns achievements the user has explicitly set to show on their profile. The second branch returns achievements where the caller has the authority to award them, covering three namespace membership paths: direct maintainer or owner membership in the achievement's group, inherited access via a parent group traversal, and group-link access (the user's group was invited into the achievement's group with maintainer-or-higher access).

Query:

SELECT "user_achievements".* FROM (
  (SELECT "user_achievements".* FROM "user_achievements"
    WHERE "user_achievements"."user_id" = $1
      AND "user_achievements"."revoked_by_user_id" IS NULL
      AND "user_achievements"."show_on_profile" = TRUE)
  UNION
  (SELECT "user_achievements".* FROM "user_achievements"
    INNER JOIN "achievements" ON "achievements"."id" = "user_achievements"."achievement_id"
    WHERE "user_achievements"."user_id" = $1
      AND "user_achievements"."revoked_by_user_id" IS NULL
      AND "achievements"."namespace_id" IN (
        SELECT "id" FROM (
          (SELECT "namespaces"."id"
            FROM "namespaces"
            INNER JOIN "members" ON "namespaces"."id" = "members"."source_id"
            WHERE "namespaces"."type" = 'Group'
              AND "members"."type" = 'GroupMember'
              AND "members"."source_type" = 'Namespace'
              AND "members"."user_id" = $1
              AND "members"."requested_at" IS NULL
              AND "members"."access_level" IN (40, 50))
          UNION
          (SELECT "namespaces"."id"
            FROM "namespaces"
            INNER JOIN "group_group_links" ON "group_group_links"."shared_group_id" = "namespaces"."id"
            WHERE "namespaces"."type" = 'Group'
              AND "group_group_links"."group_access" IN (50, 40)
              AND "group_group_links"."shared_with_group_id" IN (
                SELECT "namespaces"."id" FROM "namespaces"
                INNER JOIN "members" ON "namespaces"."id" = "members"."source_id"
                WHERE "namespaces"."type" = 'Group'
                  AND "members"."type" = 'GroupMember'
                  AND "members"."source_type" = 'Namespace'
                  AND "members"."user_id" = $1
                  AND "members"."requested_at" IS NULL
                  AND "members"."access_level" IN (40, 50)
              ))
        ) awarder_namespaces
      ))
) user_achievements
ORDER BY "user_achievements"."priority" ASC NULLS LAST, "user_achievements"."id" ASC

EXPLAIN plan (postgres.ai):

Sort  (cost=77.37..77.38 rows=2 width=77) (actual time=52.256..52.261 rows=2 loops=1)
  Sort Key: user_achievements.priority, user_achievements.id
  Sort Method: quicksort  Memory: 25kB
  Buffers: shared hit=104 read=46 dirtied=3
  ->  Unique  (cost=77.30..77.36 rows=2 width=77) (actual time=52.239..52.247 rows=2 loops=1)
        Buffers: shared hit=104 read=46 dirtied=3
        ->  Sort  (cost=77.30..77.30 rows=2 width=77) (actual time=52.238..52.241 rows=2 loops=1)
              Sort Method: quicksort  Memory: 25kB
              Buffers: shared hit=104 read=46 dirtied=3
              ->  Append  (cost=0.29..77.29 rows=2 width=77) (actual time=3.083..52.195 rows=2 loops=1)
                    Buffers: shared hit=104 read=46 dirtied=3
                    ->  Index Scan using index_user_achievements_on_user_id_revoked_by_is_null on public.user_achievements  (cost=0.29..3.31 rows=1 width=77) (actual time=3.081..11.109 rows=2 loops=1)
                          Index Cond: ((user_achievements.user_id = 31079125) AND ((user_achievements.revoked_by_user_id IS NULL) = true))
                          Filter: user_achievements.show_on_profile
                          Rows Removed by Filter: 0
                          Buffers: shared hit=3 read=4
                    ->  Nested Loop Semi Join  (cost=1.56..73.97 rows=1 width=77) (actual time=41.082..41.083 rows=0 loops=1)
                          Buffers: shared hit=101 read=42 dirtied=3
                          ->  Nested Loop  (cost=0.43..6.54 rows=1 width=85) (actual time=0.025..0.633 rows=2 loops=1)
                                Buffers: shared hit=6 read=2
                                ->  Index Scan using index_user_achievements_on_user_id_revoked_by_is_null on public.user_achievements user_achievements_1  (cost=0.29..3.31 rows=1 width=77) (actual time=0.017..0.022 rows=2 loops=1)
                                      Index Cond: ((user_achievements_1.user_id = 31079125) AND ((user_achievements_1.revoked_by_user_id IS NULL) = true))
                                      Buffers: shared hit=4
                                ->  Index Scan using achievements_pkey on public.achievements  (cost=0.14..3.16 rows=1 width=16) (actual time=0.303..0.303 rows=1 loops=2)
                                      Index Cond: (achievements.id = user_achievements_1.achievement_id)
                                      Buffers: shared hit=2 read=2
                          ->  Subquery Scan on awarder_namespaces  (cost=1.13..67.42 rows=1 width=8) (actual time=20.222..20.222 rows=0 loops=2)
                                Buffers: shared hit=95 read=40 dirtied=3
                                ->  HashAggregate  (cost=1.13..67.40 rows=2 width=8) (actual time=20.219..20.219 rows=0 loops=2)
                                      Group Key: namespaces.id
                                      Buffers: shared hit=95 read=40 dirtied=3
                                      ->  Append  (cost=0.57..67.39 rows=2 width=8) (actual time=8.390..20.218 rows=0 loops=2)
                                            Buffers: shared hit=95 read=40 dirtied=3
                                            ->  Index Scan using index_namespaces_on_type_and_id on public.namespaces  (cost=0.57..3.59 rows=1 width=8) (actual time=5.548..5.557 rows=1 loops=2)
                                                  Index Cond: (((namespaces.type)::text = 'Group'::text) AND (namespaces.id = achievements.namespace_id))
                                                  Filter: (members.access_level = ANY ('{40,50}'::integer[]))
                                                  Rows Removed by Filter: 1
                                                  Buffers: shared hit=53 read=20 dirtied=3
                                            ->  Index Scan using index_namespaces_on_type_and_id on public.namespaces namespaces_1  (cost=0.57..63.78 rows=1 width=8) (actual time=14.661..14.661 rows=0 loops=2)
                                                  Index Cond: (((namespaces_1.type)::text = 'Group'::text) AND (namespaces_1.id = achievements.namespace_id))
                                                  Buffers: shared hit=42 read=20

Total execution is 58ms (planning 6ms, execution 52ms). Both user_achievements scans use index_user_achievements_on_user_id_revoked_by_is_null scoped by user_id. Namespace resolution uses index_namespaces_on_type_and_id throughout. The awarder_namespaces subquery runs as a HashAggregate to deduplicate the two-branch UNION, evaluated per matching achievement rather than as a full table scan. No full table scans occur on any large table.

References

Related to: #598828

Screenshots or screen recordings

This is a backend-only change to a GraphQL resolver. No UI changes were made. Screenshots are not applicable.

How to set up and validate locally

  1. Start your GDK environment and open a Rails console:

    gdk start
    cd gitlab && rails console
  2. Create a group, an achievement, and two users:

    group = Group.find_by_path('your-group') || Group.create!(name: 'Test Group', path: 'test-group-awarder')
    owner = User.find_by_username('your-admin-user')
    awarder_user = User.create!(name: 'Awarder', username: 'test-awarder', email: 'awarder@example.com', password: 'test1234', confirmed_at: Time.now)
    recipient_user = User.create!(name: 'Recipient', username: 'test-recipient', email: 'recipient@example.com', password: 'test1234', confirmed_at: Time.now)
    group.add_maintainer(awarder_user)
    achievement = Achievements::Achievement.create!(namespace: group, name: 'Hidden Test', created_by_user: owner)
  3. Award the achievement with show_on_profile: false to the recipient:

    Achievements::UserAchievement.create!(
      achievement: achievement,
      user: recipient_user,
      awarded_by_user: owner,
      show_on_profile: false
    )
  4. Open the GraphQL explorer at http://localhost:3000/-/graphql-explorer and authenticate as awarder_user.

  5. Run the following query with includeHidden: true:

    {
      user(username: "test-recipient") {
        userAchievements(includeHidden: true) {
          nodes {
            achievement { name }
            showOnProfile
          }
        }
      }
    }

    Expected: the hidden achievement is returned in nodes.

  6. Repeat the query authenticated as a user with no membership in the group. Expected: the hidden achievement is not returned.

  7. Run the existing request spec to confirm all cases pass:

    bundle exec rspec spec/requests/api/graphql/achievements/user_achievements_query_spec.rb

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.

Edited by Mattias Michaux

Merge request reports

Loading