Knowledge Graph AuthZ Rails Redaction Endpoint

Implement a reusable batch authorization service (Authz::RedactionService) that checks whether a user has read access to a collection of resources. This service is the foundation for Layer 3: Final Redaction Layer in the Knowledge Graph security architecture.

Background

Knowledge Graph Security Architecture

The GitLab Knowledge Graph (GKG) service provides intelligent search and discovery across SDLC data stored in ClickHouse. The security architecture consists of three layers:

  1. Layer 1: Query-time Filtering - ClickHouse queries use traversal IDs to filter results at the group/project level
  2. Layer 2: Pre-filtering - Additional filtering based on user's project authorizations
  3. Layer 3: Final Redaction - Rails-based authorization using Ability.allowed? for fine-grained access control

This issue focuses on Layer 3, which is necessary because traversal ID filtering cannot account for:

  • Confidential issues - Only visible to project members and issue participants
  • Runtime checks - SAML group links, IP restrictions
  • Custom roles - Fine-grained permissions beyond Reporter+ access
  • Resource-specific visibility - Feature access levels, banned/blocked users

Why a Separate Service?

While this service is being built for Knowledge Graph, it's designed to be generic and reusable:

  • Follows the same pattern as SearchService#visible_result?
  • Can be used by any feature requiring batch authorization checks
  • Placed in CE (Authz:: namespace) for broader availability
  • EE extension adds support for EE-specific resources (epics, vulnerabilities)

Requirements

Functional Requirements

  1. Accept a user and a hash of resources grouped by type
  2. Support resource types: issues, merge_requests, projects, milestones, snippets (CE) + epics, vulnerabilities (EE)
  3. Return a hash mapping each resource ID to a boolean authorization result
  4. Use GitLab's standard Ability.allowed? for authorization decisions
  5. Accept an optional logger parameter for audit logging

Non-Functional Requirements

  1. Performance: Prevent N+1 queries through association preloading
  2. Consistency: Follow the same authorization pattern as SearchService
  3. Scalability: Support batch sizes up to 500 resources per request

Technical Design

Service Interface

# CE: app/services/authz/redaction_service.rb
service = Authz::RedactionService.new(
  user: current_user,
  resources_by_type: {
    'issues' => [123, 456],
    'merge_requests' => [789]
  },
  logger: optional_logger
)

result = service.execute
# => {
#      'issues' => { 123 => true, 456 => false },
#      'merge_requests' => { 789 => true }
#    }

N+1 Prevention Strategy

The service uses association preloading to prevent N+1 queries during policy evaluation:

PRELOAD_ASSOCIATIONS = {
  'issues' => [
    { project: [:namespace, :project_feature, :group] },
    :author,
    :work_item_type
  ],
  'merge_requests' => [
    { target_project: [:namespace, :project_feature, :group] },
    :author
  ],
  'projects' => [:namespace, :project_feature, :group],
  'milestones' => [{ project: [:namespace, :project_feature] }, :group],
  'snippets' => [{ project: [:namespace, :project_feature] }, :author]
}

How it works:

Without preloading, checking 100 issues would trigger 300+ queries:

  • 1 query to load issues
  • For each issue: queries for project, namespace, project_feature, etc.

With preloading, the same operation uses ~4 queries regardless of batch size:

  • 1 query for issues with JOINs/eager loading for all associations
  • Policy evaluation uses already-loaded data

DeclarativePolicy Optimization

The service wraps authorization checks in DeclarativePolicy.user_scope:

DeclarativePolicy.user_scope do
  resources_by_type.each_with_object({}) do |(type, ids), results|
    results[type] = authorize_resources_of_type(type, ids, loaded_resources)
  end
end

This caches policy evaluations for the same user across multiple resources, following the pattern from Ability.issues_readable_by_user.

Authorization Pattern

Uses the same visible_result? pattern as SearchService:

def visible_result?(resource)
  return true unless resource.respond_to?(:to_ability_name) && DeclarativePolicy.has_policy?(resource)

  Ability.allowed?(user, :"read_#{resource.to_ability_name}", resource)
end

Assumptions

  1. User is already authenticated: The service does NOT validate user state (blocked, deactivated). The caller is responsible for ensuring the user is valid.
  2. Resources exist: Non-existent resource IDs return false (denied)
  3. Batch size is reasonable: The API endpoint (separate issue) will enforce max batch size

References

Edited by Michael Angelo Rivera