Fix KG auth with Short Lived Cache for GroupsFinder and ProjectsFinder

Summary

Follow-up to merged MR !231605 (merged). Fixes an authorization regression where users whose only reporter+ access to a KG-enabled namespace comes through a group_group_link received HTTP 403 on the Orbit API, even though their JWT included the namespace's traversal prefix.

Also picks up John's Redis cache for Search::GroupsFinder / Search::ProjectsFinder (!224795 (closed)), relocates the cache-busting worker to the Search namespace, and expands the event-store subscriptions so finder caches stay consistent with every membership change path.

Part of #591202 (closed)

Prior art !224372 (merged)

What changed

  • Gate uses Search::GroupsFinder (same as the JWT) so group_group_link access is honored
  • AuthorizationContext runs GroupsFinder once per request; gate + JWT share the result
  • AuthorizationContext no longer keeps its own Rails.cache — finder owns the cache
  • Search finders cache-bust by bumping a per-user version, covering every min_access_level/features combo
  • Rails.cache.write_multi batches invalidation (one pipelined Redis call per finder)
  • Worker renamed Analytics::KnowledgeGraph::ExpireTraversalIdCacheWorkerSearch::ExpireFinderCacheWorker and moved to SearchSubscriptions
  • Subscribes to Members::UpdatedEvent, Members::AcceptedInviteEvent, Members::MembershipModifiedByAdminEvent in addition to the previous four

How to reproduce and validate locally

Setup

# Rails console
admin = User.find(1)
root_enabled  = Group.find_or_create_by!(path: 'kg-enabled')  { |g| g.name = 'KG Enabled'; g.organization_id = admin.organization_id }
other_group   = Group.find_or_create_by!(path: 'kg-shared')   { |g| g.name = 'KG Shared';  g.organization_id = admin.organization_id }
Analytics::KnowledgeGraph::EnabledNamespace.find_or_create_by!(root_namespace_id: root_enabled.id)

user = User.find_or_create_by!(username: 'kg-linked') do |u|
  u.email = 'kg-linked@example.com'
  u.name  = 'KG Linked'
  u.password = SecureRandom.hex(12)
  u.confirmed_at = Time.current
  u.organization_id = admin.organization_id
end
Organizations::OrganizationUser.find_or_create_by!(user_id: user.id, organization_id: admin.organization_id)

# Share the KG-enabled group with the other group at REPORTER level,
# then give the user reporter on the other (non-KG-enabled) group.
GroupGroupLink.find_or_create_by!(shared_group: root_enabled, shared_with_group: other_group) do |link|
  link.group_access = Gitlab::Access::REPORTER
end
Member.where(user_id: user.id).delete_all
other_group.add_reporter(user)
Users::RefreshAuthorizedProjectsService.new(user).execute

Reproduce the regression (before this MR)

ctx = Analytics::KnowledgeGraph::AuthorizationContext.new(user)
ctx.reporter_plus_traversal_ids[:group_traversal_ids]
#=> ["1/<root_enabled.id>/", "1/<other_group.id>/"]   <-- JWT sees the enabled namespace
ctx.has_enabled_namespaces?
#=> false                                             <-- gate blocks the user

POST to /api/v4/orbit/query with a PAT from that user and it returns 403 No Knowledge Graph enabled namespaces available.

Validate the fix (with this MR)

ctx = Analytics::KnowledgeGraph::AuthorizationContext.new(user)
ctx.has_enabled_namespaces?   #=> true

Then:

TOKEN=<user PAT>
curl -sk -H "PRIVATE-TOKEN: $TOKEN" -H 'Content-Type: application/json' \
  -X POST "https://gdk.test:3443/api/v4/orbit/query" \
  -d '{"query":{"query_type":"search","node":{"id":"g","entity":"Group","columns":["full_path"]},"limit":20}}'

Returns HTTP 200 with the rows the user reaches through the link.

Validate the share-level boundary

link = GroupGroupLink.find_by(shared_group: root_enabled, shared_with_group: other_group)
link.update!(group_access: Gitlab::Access::GUEST)
Users::RefreshAuthorizedProjectsService.new(user).execute

Analytics::KnowledgeGraph::AuthorizationContext.new(user.reload).has_enabled_namespaces?
#=> false   <-- guest-level shares do not pass

Cache invalidation smoke test

user = User.find(1)
k_before = Search::GroupsFinder.redis_cache_key(user.id, min_access_level: Gitlab::Access::REPORTER)
Search::GroupsFinder.expire_cache_for_users([user.id])
k_after  = Search::GroupsFinder.redis_cache_key(user.id, min_access_level: Gitlab::Access::REPORTER)
k_before != k_after   #=> true (version bumped, every variant orphaned)

Tests

  • ee/spec/lib/analytics/knowledge_graph/authorization_context_spec.rb covers subgroup access, group_group_link at REPORTER (passes) and GUEST (blocks), mixed enabled/disabled access, and the existing boundary cases.
  • ee/spec/workers/search/expire_finder_cache_worker_spec.rb covers the rename, subscriptions to all seven events (four existing plus the three new ones), member_user_id extraction, and the write_multi invalidation path.
  • ee/spec/finders/search/{groups,projects}_finder_spec.rb were updated to build keys through redis_cache_key so they stay in sync with the version scheme.

Notes

  • search_finders_redis_cache remains default_enabled: false. Rollout is owned by Global Search.
  • No worker-rename compatibility shim is needed because the previous class was only subscribed behind the disabled knowledge_graph_infra flag.
Edited by Michael Angelo Rivera

Merge request reports

Loading