diff --git a/lib/tasks/gitlab/graphql.rake b/lib/tasks/gitlab/graphql.rake index 4e11ea29601f0dd6a7da174c69b6e5d807643cbf..3f136321cc367392c6c31788e0dc2d7616112d88 100644 --- a/lib/tasks/gitlab/graphql.rake +++ b/lib/tasks/gitlab/graphql.rake @@ -162,6 +162,145 @@ namespace :gitlab do desc 'GitLab | GraphQL | Update GraphQL docs and schema' task update_all: [:compile_docs, 'schema:dump'] + + desc 'GitLab | GraphQL | Validate AI workflows scope for GraphQL query' + task :validate_ai_workflows_scope, [:query_file] => [:environment, :enable_feature_flags] do |t, args| + # This task validates that a GraphQL query only uses types and fields that have proper + # AI workflows authorization. It checks: + # 1. Each type involved in the query has :ai_workflows in its authorization_scopes + # 2. Each field used in the query has :ai_workflows in its scopes + # + # Usage: bundle exec rake gitlab:graphql:validate_ai_workflows_scope[path/to/query.graphql] + # + # Note: Complex queries with inline fragments and fragment spreads may produce + # some false positives due to type context tracking limitations. The task works + # best with simpler, direct field selection queries. + query_file = args[:query_file] + + unless query_file && File.exist?(query_file) + format_output("Query file is required and must exist. Usage: bundle exec rake gitlab:graphql:validate_ai_workflows_scope[path/to/query.graphql]") + abort + end + + query_content = File.read(query_file) + + begin + query = GraphQL::Query.new(GitlabSchema, query_content) + rescue GraphQL::ParseError => e + format_output("Failed to parse GraphQL query: #{e.message}") + abort + rescue StandardError => e + format_output("Failed to analyze GraphQL query: #{e.message}") + abort + end + + violations = {} + + # Create a custom analyzer to check AI workflows scope requirements + ai_scope_analyzer = Class.new(GraphQL::Analysis::AST::Analyzer) do + def initialize(query) + super + @violations = {} + @processed_types = Set.new + end + + def on_enter_field(node, _parent, visitor) + # Always ignore __typename field + return if node.name == '__typename' + + current_type = visitor.parent_type_definition + return unless current_type + + # Only validate on object types that have fields + return unless current_type.respond_to?(:get_field) + + field_def = current_type.get_field(node.name) + if field_def + validate_type_authorization_scopes(current_type) + validate_field_scopes(field_def, node, current_type) + else + add_violation(current_type.graphql_name, :field_not_found, node.name) + end + end + + def result + @violations + end + + private + + def validate_type_authorization_scopes(current_type) + type_name = current_type.graphql_name + return if @processed_types.include?(type_name) + + @processed_types.add(type_name) + + return unless current_type.respond_to?(:authorization_scopes) + + auth_scopes = current_type.authorization_scopes + return if auth_scopes.include?(:ai_workflows) + + add_violation(type_name, :missing_type_auth_scope) + end + + def validate_field_scopes(field_def, node, current_type) + field_scopes = field_def.instance_variable_get(:@scopes) if field_def.respond_to?(:instance_variable_get) + type_name = current_type.graphql_name + + if field_scopes && field_scopes.any? + validate_existing_field_scopes(field_scopes, node, type_name) + else + validate_missing_field_scopes(node, type_name) + end + end + + def validate_existing_field_scopes(field_scopes, node, type_name) + return if field_scopes.include?(:ai_workflows) + + add_violation(type_name, :field_missing_ai_scope, node.name) + end + + def validate_missing_field_scopes(node, type_name) + # Only report missing scopes for fields that are not introspection fields + return if node.name.start_with?('__') + + add_violation(type_name, :field_missing_ai_scope, node.name) + end + + def add_violation(type_name, violation_type, field_name = nil) + @violations[type_name] ||= { + missing_type_auth_scope: false, + field_not_found: [], + field_missing_ai_scope: [] + } + + case violation_type + when :missing_type_auth_scope + @violations[type_name][:missing_type_auth_scope] = true + when :field_not_found + @violations[type_name][:field_not_found] << field_name + when :field_missing_ai_scope + @violations[type_name][:field_missing_ai_scope] << field_name + end + end + end + + # Analyze the query + begin + analysis_result = GraphQL::Analysis::AST.analyze_query(query, [ai_scope_analyzer]) + violations = analysis_result.first + rescue StandardError => e + format_output("Failed during GraphQL analysis: #{e.message}") + abort + end + + if violations.any? + format_ai_workflows_violations(query_file, violations) + abort + else + puts "✓ AI workflows scope validation passed for #{query_file}" + end + end end end @@ -180,3 +319,30 @@ def format_output(*strs) puts '#' puts heading end + +def format_ai_workflows_violations(query_file, violations) + puts "AI workflows scope validation failed for #{query_file}:" + puts + + violations.each do |type_name, issues| + type_line = "* type #{type_name}" + type_line += " (missing authorization scope)" if issues[:missing_type_auth_scope] + puts type_line + + if issues[:field_not_found].any? + puts " * not found fields:" + issues[:field_not_found].uniq.sort.each do |field| + puts " * #{field}" + end + end + + if issues[:field_missing_ai_scope].any? + puts " * fields missing ai_workflows scope:" + issues[:field_missing_ai_scope].uniq.sort.each do |field| + puts " * #{field}" + end + end + + puts + end +end