From f2333f5e6f370301c64d43687e98209789a6e47a Mon Sep 17 00:00:00 2001 From: Joe Woodward Date: Mon, 3 Jun 2024 12:01:17 +0100 Subject: [PATCH] Add GraphQL mutation `projectTextReplace` Allows owners and administrators to replace text via the GraphQL API. Takes 2 arguments: projectPath - full path of the project to redact replacements - list of replacements Valid `replacements` values are documented here https://htmlpreview.github.io/?https://github.com/newren/git-filter-repo/blob/docs/html/git-filter-repo.html#_content_based_filtering NOTE: We do not support regex replacements to prevent potential ReDoS attacks. Returns nothing when successful Returns errors when something went wrong This mutation is audited. Part of https://gitlab.com/gitlab-org/gitlab/-/issues/450701 Changelog: added --- .../mutations/projects/text_replace.rb | 69 ++++++++ app/graphql/types/mutation_type.rb | 1 + .../types/project_text_replacement.yml | 10 ++ doc/api/graphql/reference/index.md | 23 +++ doc/user/compliance/audit_event_types.md | 1 + .../mutations/projects/text_replace_spec.rb | 58 +++++++ .../mutations/projects/text_replace_spec.rb | 162 ++++++++++++++++++ 7 files changed, 324 insertions(+) create mode 100644 app/graphql/mutations/projects/text_replace.rb create mode 100644 config/audit_events/types/project_text_replacement.yml create mode 100644 ee/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb create mode 100644 spec/requests/api/graphql/mutations/projects/text_replace_spec.rb diff --git a/app/graphql/mutations/projects/text_replace.rb b/app/graphql/mutations/projects/text_replace.rb new file mode 100644 index 00000000000000..b6dda9831bb2a2 --- /dev/null +++ b/app/graphql/mutations/projects/text_replace.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Mutations + module Projects + class TextReplace < BaseMutation + graphql_name 'projectTextReplace' + + include FindsProject + + UNSUPPORTED_REPLACEMENT_PREFIX = %r{(?:regex|glob):} + SUPPORTED_REPLACEMENT_PREFIX = %r{literal:} + EMPTY_REPLACEMENTS_ARG_ERROR = <<~ERROR + Argument 'replacements' on InputObject 'projectTextReplaceInput' is required. Expected type [String!]! + ERROR + UNSUPPORTED_REPLACEMENTS_ARG_ERROR = <<~ERROR + Argument 'replacements' on InputObject 'projectTextReplaceInput' does not support 'regex:' or 'glob:' values. + ERROR + + authorize :owner_access + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Full path of the project to replace.' + + argument :replacements, [GraphQL::Types::String], + required: true, + description: 'List of text patterns to replace project-wide.', + prepare: ->(replacements, _ctx) do + replacements.reject!(&:blank?) + + raise(GraphQL::ExecutionError, EMPTY_REPLACEMENTS_ARG_ERROR) if replacements.empty? + + if replacements.any? { |r| r.starts_with?(UNSUPPORTED_REPLACEMENT_PREFIX) } + raise(GraphQL::ExecutionError, UNSUPPORTED_REPLACEMENTS_ARG_ERROR) + end + + replacements.map { |r| r.starts_with?(SUPPORTED_REPLACEMENT_PREFIX) ? r : "literal:#{r}" } + end + + def resolve(project_path:, replacements:) + project = authorized_find!(project_path) + + begin + project.set_repository_read_only! + client = Gitlab::GitalyClient::CleanupService.new(project.repository) + client.rewrite_history(redactions: replacements) + + audit_replacements(project) + + { errors: [] } + ensure + project.set_repository_writable! + end + end + + def audit_replacements(project) + context = { + name: 'project_text_replacement', + author: current_user, + scope: project, + target: project, + message: 'Project text replaced' + } + + ::Gitlab::Audit::Auditor.audit(context) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index b577008fb5b72e..9f99ff1993b487 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -113,6 +113,7 @@ class MutationType < BaseObject mount_mutation Mutations::Organizations::Update, alpha: { milestone: '16.7' } mount_mutation Mutations::Projects::BlobsRemove, calls_gitaly: true, alpha: { milestone: '17.1' } mount_mutation Mutations::Projects::SyncFork, calls_gitaly: true, alpha: { milestone: '15.9' } + mount_mutation Mutations::Projects::TextReplace, calls_gitaly: true, alpha: { milestone: '17.1' } mount_mutation Mutations::Projects::Star, alpha: { milestone: '16.7' } mount_mutation Mutations::BranchRules::Update, alpha: { milestone: '16.7' } mount_mutation Mutations::BranchRules::Create, alpha: { milestone: '16.7' } diff --git a/config/audit_events/types/project_text_replacement.yml b/config/audit_events/types/project_text_replacement.yml new file mode 100644 index 00000000000000..7120fb9a541ced --- /dev/null +++ b/config/audit_events/types/project_text_replacement.yml @@ -0,0 +1,10 @@ +--- +name: project_text_replacement +description: Triggered when replacing text via the GraphQL API or project settings UI +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/450701 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/152522 +feature_category: source_code_management +milestone: '17.1' +saved_to_database: true +streamed: true +scope: [Project] diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index fdf110f9e36b52..aaec160b4ef0be 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -7602,6 +7602,29 @@ Input type: `ProjectSyncForkInput` | `details` | [`ForkDetails`](#forkdetails) | Updated fork details. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.projectTextReplace` + +DETAILS: +**Introduced** in GitLab 17.1. +**Status**: Experiment. + +Input type: `projectTextReplaceInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `projectPath` | [`ID!`](#id) | Full path of the project to replace. | +| `replacements` | [`[String!]!`](#string) | List of text patterns to replace project-wide. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.prometheusIntegrationCreate` Input type: `PrometheusIntegrationCreateInput` diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index d42185b7c18bd0..c9dcf06eca2c0c 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -457,6 +457,7 @@ Audit event types belong to the following product categories. | [`repository_git_operation`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76719) | Triggered when authenticated users push, pull, or clone a project using SSH, HTTP(S), or the UI| **{dotted-circle}** No | **{check-circle}** Yes | GitLab [14.9](https://gitlab.com/gitlab-org/gitlab/-/issues/373950) | Project | | [`manually_trigger_housekeeping`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112095) | Triggered when manually triggering housekeeping via API or admin UI| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [15.9](https://gitlab.com/gitlab-org/gitlab/-/issues/390761) | Project | | [`project_blobs_removal`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/152522) | Triggered when removing blobs via the GraphQL API or project settings UI| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.0](https://gitlab.com/gitlab-org/gitlab/-/issues/450701) | Project | +| [`project_text_replacement`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/152522) | Triggered when replacing text via the GraphQL API or project settings UI| **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.1](https://gitlab.com/gitlab-org/gitlab/-/issues/450701) | Project | ### Subgroup diff --git a/ee/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb new file mode 100644 index 00000000000000..979e6f6293f527 --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "projectTextReplace", feature_category: :source_code_management do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:current_user) { create(:user, owner_of: project) } + let_it_be(:repo) { project.repository } + + let(:project_path) { project.full_path } + let(:mutation_params) { { project_path: project_path, replacements: replacements } } + let(:mutation) { graphql_mutation(:project_text_replace, mutation_params) } + let(:replacements) { %w[p455w0rd] } + let(:literal_replacements) { %w[literal:p455w0rd] } + + subject(:post_mutation) { post_graphql_mutation(mutation, current_user: current_user) } + + describe 'Replacing text' do + before do + ::Gitlab::GitalyClient.clear_stubs! + + allow_next_instance_of(Gitaly::CleanupService::Stub) do |instance| + redactions = array_including(gitaly_request_with_params(redactions: literal_replacements)) + allow(instance).to receive(:rewrite_history) + .with(redactions, kind_of(Hash)) + .and_return(Gitaly::RewriteHistoryResponse.new) + end + end + + context 'when audit events are licensed' do + before do + stub_licensed_features(audit_events: true) + end + + it 'audits the changes' do + expect { post_mutation }.to change { AuditEvent.count }.from(0).to(1) + expect(AuditEvent.first.attributes.deep_symbolize_keys).to match a_hash_including( + author_id: current_user.id, + entity_id: project.id, + target_id: project.id, + details: a_hash_including(custom_message: 'Project text replaced') + ) + end + end + + context 'when audit events are not licensed' do + before do + stub_licensed_features(audit_events: false) + end + + it 'does not audit the change' do + expect { post_mutation }.not_to change { AuditEvent.count } + end + end + end +end diff --git a/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb b/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb new file mode 100644 index 00000000000000..87d3d6a3cd0891 --- /dev/null +++ b/spec/requests/api/graphql/mutations/projects/text_replace_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "projectTextReplace", feature_category: :source_code_management do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:current_user) { create(:user, owner_of: project) } + let_it_be(:repo) { project.repository } + + let(:project_path) { project.full_path } + let(:mutation_params) { { project_path: project_path, replacements: replacements } } + let(:mutation) { graphql_mutation(:project_text_replace, mutation_params) } + let(:replacements) do + %w[ + p455w0rd + foo==>bar + literal:MM/DD/YYYY==>YYYY-MM-DD + ] + end + + let(:literal_replacements) do + %w[ + literal:p455w0rd + literal:foo==>bar + literal:MM/DD/YYYY==>YYYY-MM-DD + ] + end + + subject(:post_mutation) { post_graphql_mutation(mutation, current_user: current_user) } + + describe 'Replacing text' do + before do + ::Gitlab::GitalyClient.clear_stubs! + end + + it 'prepends `literal:` to implicit replacements before submitting to rewriteHistory RPC' do + expect_next_instance_of(Gitaly::CleanupService::Stub) do |instance| + redactions = array_including(gitaly_request_with_params(redactions: literal_replacements)) + expect(instance).to receive(:rewrite_history) + .with(redactions, kind_of(Hash)) + .and_return(Gitaly::RewriteHistoryResponse.new) + end + + post_mutation + + expect(graphql_mutation_response(:project_text_replace)['errors']).not_to be_present + end + + it 'does not audit the change in CE' do + allow_next_instance_of(Gitaly::CleanupService::Stub) do |instance| + redactions = array_including(gitaly_request_with_params(redactions: literal_replacements)) + allow(instance).to receive(:rewrite_history) + .with(redactions, kind_of(Hash)) + .and_return(Gitaly::RewriteHistoryResponse.new) + end + + expect { post_mutation }.not_to change { AuditEvent.count } + end + end + + describe 'Invalid requests:' do + context 'when the current_user is a maintainer' do + let(:current_user) { create(:user, maintainer_of: project) } + + it_behaves_like 'a mutation on an unauthorized resource' + end + + context 'when arg `projectPath` is invalid' do + let(:project_path) { 'gid://Gitlab/User/1' } + + it 'returns an error' do + post_mutation + + expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE.strip)) + The resource that you are attempting to access does not exist or you don't have permission to perform this action + MESSAGE + end + end + + context 'when arg `replacements` is nil' do + let(:replacements) { nil } + + it 'returns an error' do + post_mutation + + expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE.strip)) + Variable $projectTextReplaceInput of type projectTextReplaceInput! was provided invalid value for replacements (Expected value to not be null) + MESSAGE + end + end + + context 'when arg `replacements` is an empty list' do + let(:replacements) { [] } + + it 'returns an error' do + post_mutation + + expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE)) + Argument 'replacements' on InputObject 'projectTextReplaceInput' is required. Expected type [String!]! + MESSAGE + end + end + + context 'when arg `replacements` does not contain any valid strings' do + let(:replacements) { ["", ""] } + + it 'returns an error' do + post_mutation + + expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE)) + Argument 'replacements' on InputObject 'projectTextReplaceInput' is required. Expected type [String!]! + MESSAGE + end + end + + context 'when arg `replacements` includes a regex' do + let(:replacements) { ['regex:\bdriver\b==>pilot'] } + + it 'returns an error' do + post_mutation + + expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE)) + Argument 'replacements' on InputObject 'projectTextReplaceInput' does not support 'regex:' or 'glob:' values. + MESSAGE + end + end + + context 'when arg `replacements` includes a glob' do + let(:replacements) { ['glob:**string**==>'] } + + it 'returns an error' do + post_mutation + + expect(graphql_errors).to include(a_hash_including('message' => <<~MESSAGE)) + Argument 'replacements' on InputObject 'projectTextReplaceInput' does not support 'regex:' or 'glob:' values. + MESSAGE + end + end + + context 'when Gitaly RPC returns an error' do + let(:error_message) { 'error message' } + + before do + ::Gitlab::GitalyClient.clear_stubs! + end + + it 'returns a generic error message' do + expect_next_instance_of(Gitaly::CleanupService::Stub) do |instance| + redactions = array_including(gitaly_request_with_params(redactions: literal_replacements)) + generic_error = GRPC::BadStatus.new(GRPC::Core::StatusCodes::FAILED_PRECONDITION, error_message) + expect(instance).to receive(:rewrite_history).with(redactions, kind_of(Hash)).and_raise(generic_error) + end + + post_mutation + + expect(graphql_errors).to include(a_hash_including('message' => "Internal server error: 9:#{error_message}")) + end + end + end +end -- GitLab