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: addsinclude FromUnionto 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=20Total 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
-
Start your GDK environment and open a Rails console:
gdk start cd gitlab && rails console -
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) -
Award the achievement with
show_on_profile: falseto the recipient:Achievements::UserAchievement.create!( achievement: achievement, user: recipient_user, awarded_by_user: owner, show_on_profile: false ) -
Open the GraphQL explorer at
http://localhost:3000/-/graphql-explorerand authenticate asawarder_user. -
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. -
Repeat the query authenticated as a user with no membership in the group. Expected: the hidden achievement is not returned.
-
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.