From a1b7007190e5c54e43fc3d6fbcfa68671a95c62a Mon Sep 17 00:00:00 2001 From: Jan Provaznik <jprovaznik@gitlab.com> Date: Thu, 28 Jul 2022 12:09:49 +0000 Subject: [PATCH] Add graphql mutation for deleting file uploads Allows to delete file upload in a project or group. Changelog: added --- app/graphql/mutations/uploads/delete.rb | 37 +++++++ app/graphql/types/mutation_type.rb | 1 + app/graphql/types/upload_type.rb | 16 +++ app/policies/group_policy.rb | 2 + app/policies/project_policy.rb | 2 + app/policies/upload_policy.rb | 5 + app/services/uploads/destroy_service.rb | 51 +++++++++ doc/api/graphql/reference/index.md | 40 +++++++ locale/gitlab.pot | 6 + spec/graphql/types/upload_type_spec.rb | 13 +++ spec/policies/upload_policy_spec.rb | 76 +++++++++++++ .../graphql/mutations/uploads/delete_spec.rb | 74 +++++++++++++ spec/services/uploads/destroy_service_spec.rb | 103 ++++++++++++++++++ .../policies/group_policy_shared_context.rb | 1 + .../policies/project_policy_shared_context.rb | 1 + 15 files changed, 428 insertions(+) create mode 100644 app/graphql/mutations/uploads/delete.rb create mode 100644 app/graphql/types/upload_type.rb create mode 100644 app/policies/upload_policy.rb create mode 100644 app/services/uploads/destroy_service.rb create mode 100644 spec/graphql/types/upload_type_spec.rb create mode 100644 spec/policies/upload_policy_spec.rb create mode 100644 spec/requests/api/graphql/mutations/uploads/delete_spec.rb create mode 100644 spec/services/uploads/destroy_service_spec.rb diff --git a/app/graphql/mutations/uploads/delete.rb b/app/graphql/mutations/uploads/delete.rb new file mode 100644 index 0000000000000..e2fb967cd2c60 --- /dev/null +++ b/app/graphql/mutations/uploads/delete.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + module Uploads + class Delete < BaseMutation + graphql_name 'UploadDelete' + description 'Deletes an upload.' + + include Mutations::ResolvesResourceParent + + authorize :destroy_upload + + argument :secret, GraphQL::Types::String, + required: true, + description: 'Secret part of upload path.' + + argument :filename, GraphQL::Types::String, + required: true, + description: 'Upload filename.' + + field :upload, Types::UploadType, + null: true, + description: 'Deleted upload.' + + def resolve(args) + parent = authorized_resource_parent_find!(args) + + result = ::Uploads::DestroyService.new(parent, current_user).execute(args[:secret], args[:filename]) + + { + upload: result[:status] == :success ? result[:upload] : nil, + errors: Array(result[:message]) + } + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 3907b096c2c9b..dc9eb369dc890 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -151,6 +151,7 @@ class MutationType < BaseObject mount_mutation Mutations::SavedReplies::Update mount_mutation Mutations::Pages::MarkOnboardingComplete mount_mutation Mutations::SavedReplies::Destroy + mount_mutation Mutations::Uploads::Delete end end diff --git a/app/graphql/types/upload_type.rb b/app/graphql/types/upload_type.rb new file mode 100644 index 0000000000000..68792fa526f64 --- /dev/null +++ b/app/graphql/types/upload_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class UploadType < BaseObject + graphql_name 'FileUpload' + + authorize :read_upload + + field :id, Types::GlobalIDType[::Upload], null: false, + description: 'Global ID of the upload.' + field :path, GraphQL::Types::String, null: false, + description: 'Path of the upload.' + field :size, GraphQL::Types::Int, null: false, + description: 'Size of the upload in bytes.' + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 50b6f4bbe1547..0ee759a6ad60d 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -181,6 +181,8 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :create_jira_connect_subscription enable :maintainer_access enable :maintain_namespace + enable :read_upload + enable :destroy_upload end rule { owner }.policy do diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 70e8542c44a6a..9d121715d2cf7 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -498,6 +498,8 @@ class ProjectPolicy < BasePolicy enable :admin_project_google_cloud enable :admin_secure_files enable :read_web_hooks + enable :read_upload + enable :destroy_upload end rule { public_project & metrics_dashboard_allowed }.policy do diff --git a/app/policies/upload_policy.rb b/app/policies/upload_policy.rb new file mode 100644 index 0000000000000..c7fde5d9df419 --- /dev/null +++ b/app/policies/upload_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class UploadPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass + delegate { @subject.model } +end diff --git a/app/services/uploads/destroy_service.rb b/app/services/uploads/destroy_service.rb new file mode 100644 index 0000000000000..1f0d99ff7bbb2 --- /dev/null +++ b/app/services/uploads/destroy_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Uploads + class DestroyService < BaseService + attr_accessor :model, :current_user + + def initialize(model, user = nil) + @model = model + @current_user = user + end + + def execute(secret, filename) + upload = find_upload(secret, filename) + + unless current_user && upload && current_user.can?(:destroy_upload, upload) + return error(_("The resource that you are attempting to access does not "\ + "exist or you don't have permission to perform this action.")) + end + + if upload.destroy + success(upload: upload) + else + error(_('Upload could not be deleted.')) + end + end + + private + + # rubocop: disable CodeReuse/ActiveRecord + def find_upload(secret, filename) + uploader = uploader_class.new(model, secret: secret) + upload_paths = uploader.upload_paths(filename) + + Upload.find_by(model: model, uploader: uploader_class.to_s, path: upload_paths) + rescue FileUploader::InvalidSecret + nil + end + # rubocop: enable CodeReuse/ActiveRecord + + def uploader_class + case model + when Group + NamespaceFileUploader + when Project + FileUploader + else + raise ArgumentError, "unknown uploader for #{model.class.name}" + end + end + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index e04ef88b277cc..7ee604207a035 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -5385,6 +5385,30 @@ Input type: `UpdateSnippetInput` | <a id="mutationupdatesnippeterrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationupdatesnippetsnippet"></a>`snippet` | [`Snippet`](#snippet) | Snippet after mutation. | +### `Mutation.uploadDelete` + +Deletes an upload. + +Input type: `UploadDeleteInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationuploaddeleteclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationuploaddeletefilename"></a>`filename` | [`String!`](#string) | Upload filename. | +| <a id="mutationuploaddeletegrouppath"></a>`groupPath` | [`ID`](#id) | Full path of the group with which the resource is associated. | +| <a id="mutationuploaddeleteprojectpath"></a>`projectPath` | [`ID`](#id) | Full path of the project with which the resource is associated. | +| <a id="mutationuploaddeletesecret"></a>`secret` | [`String!`](#string) | Secret part of upload path. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationuploaddeleteclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationuploaddeleteerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +| <a id="mutationuploaddeleteupload"></a>`upload` | [`FileUpload`](#fileupload) | Deleted upload. | + ### `Mutation.userCalloutCreate` Input type: `UserCalloutCreateInput` @@ -11845,6 +11869,16 @@ Represents an external issue. | <a id="externalissueupdatedat"></a>`updatedAt` | [`Time`](#time) | Timestamp of when the issue was updated. | | <a id="externalissueweburl"></a>`webUrl` | [`String`](#string) | URL to the issue in the external tracker. | +### `FileUpload` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="fileuploadid"></a>`id` | [`UploadID!`](#uploadid) | Global ID of the upload. | +| <a id="fileuploadpath"></a>`path` | [`String!`](#string) | Path of the upload. | +| <a id="fileuploadsize"></a>`size` | [`Int!`](#int) | Size of the upload in bytes. | + ### `GeoNode` #### Fields @@ -21247,6 +21281,12 @@ A regexp containing patterns sourced from user input. ### `Upload` +### `UploadID` + +A `UploadID` is a global ID. It is encoded as a string. + +An example `UploadID` is: `"gid://gitlab/Upload/1"`. + ### `UserID` A `UserID` is a global ID. It is encoded as a string. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5f0058380c008..d7644247f4b9d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -39170,6 +39170,9 @@ msgstr "" msgid "The repository must be accessible over %{code_open}http://%{code_close}, %{code_open}https://%{code_close}, %{code_open}ssh://%{code_close} or %{code_open}git://%{code_close}." msgstr "" +msgid "The resource that you are attempting to access does not exist or you don't have permission to perform this action." +msgstr "" + msgid "The same shared runner executes code from multiple projects, unless you configure autoscaling with %{link} set to 1 (which it is on GitLab.com)." msgstr "" @@ -41685,6 +41688,9 @@ msgstr "" msgid "Upload a private key for your certificate" msgstr "" +msgid "Upload could not be deleted." +msgstr "" + msgid "Upload file" msgstr "" diff --git a/spec/graphql/types/upload_type_spec.rb b/spec/graphql/types/upload_type_spec.rb new file mode 100644 index 0000000000000..2b959fbf105f2 --- /dev/null +++ b/spec/graphql/types/upload_type_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['FileUpload'] do + it { expect(described_class).to require_graphql_authorizations(:read_upload) } + + it 'has the expected fields' do + expected_fields = %w[id size path] + + expect(described_class).to include_graphql_fields(*expected_fields) + end +end diff --git a/spec/policies/upload_policy_spec.rb b/spec/policies/upload_policy_spec.rb new file mode 100644 index 0000000000000..1169df0b30068 --- /dev/null +++ b/spec/policies/upload_policy_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe UploadPolicy do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:guest) { create(:user).tap { |user| group.add_guest(user) } } + let_it_be(:developer) { create(:user).tap { |user| group.add_developer(user) } } + let_it_be(:maintainer) { create(:user).tap { |user| group.add_maintainer(user) } } + let_it_be(:owner) { create(:user).tap { |user| group.add_owner(user) } } + let_it_be(:admin) { create(:admin) } + let_it_be(:non_member_user) { create(:user) } + + let(:upload_permissions) { [:read_upload, :destroy_upload] } + + shared_examples_for 'uploads policy' do + subject { described_class.new(current_user, upload) } + + context 'when user is guest' do + let(:current_user) { guest } + + it { is_expected.to be_disallowed(*upload_permissions) } + end + + context 'when user is developer' do + let(:current_user) { developer } + + it { is_expected.to be_disallowed(*upload_permissions) } + end + + context 'when user is maintainer' do + let(:current_user) { maintainer } + + it { is_expected.to be_allowed(*upload_permissions) } + end + + context 'when user is owner' do + let(:current_user) { owner } + + it { is_expected.to be_allowed(*upload_permissions) } + end + + context 'when user is admin' do + let(:current_user) { admin } + + it { is_expected.to be_disallowed(*upload_permissions) } + + context 'with admin mode', :enable_admin_mode do + it { is_expected.to be_allowed(*upload_permissions) } + end + end + end + + describe 'destroy_upload' do + context 'when deleting project upload' do + let_it_be(:upload) { create(:upload, model: project) } + + it_behaves_like 'uploads policy' + end + + context 'when deleting group upload' do + let_it_be(:upload) { create(:upload, model: group) } + + it_behaves_like 'uploads policy' + end + + context 'when deleting upload associated with other model' do + let_it_be(:upload) { create(:upload, model: maintainer) } + + subject { described_class.new(maintainer, upload) } + + it { is_expected.to be_disallowed(*upload_permissions) } + end + end +end diff --git a/spec/requests/api/graphql/mutations/uploads/delete_spec.rb b/spec/requests/api/graphql/mutations/uploads/delete_spec.rb new file mode 100644 index 0000000000000..f44bf179397c5 --- /dev/null +++ b/spec/requests/api/graphql/mutations/uploads/delete_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Delete an upload' do + include GraphqlHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:developer) { create(:user).tap { |user| group.add_developer(user) } } + let_it_be(:maintainer) { create(:user).tap { |user| group.add_maintainer(user) } } + + let(:extra_params) { {} } + let(:params) { { filename: File.basename(upload.path), secret: upload.secret }.merge(extra_params) } + let(:mutation) { graphql_mutation(:uploadDelete, params) } + let(:mutation_response) { graphql_mutation_response(:upload_delete) } + + shared_examples_for 'upload deletion' do + context 'when the user is not allowed to delete uploads' do + let(:current_user) { developer } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when the user is anonymous' do + let(:current_user) { nil } + + it_behaves_like 'a mutation that returns a top-level access error' + end + + context 'when user has permissions to delete uploads' do + let(:current_user) { maintainer } + + it 'deletes the upload' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['upload']).to include('id' => upload.to_global_id.to_s) + expect(mutation_response['errors']).to be_empty + end + + context 'when upload does not exist' do + let(:params) { { filename: 'invalid', secret: upload.secret }.merge(extra_params) } + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(response).to have_gitlab_http_status(:success) + expect(mutation_response['upload']).to be_nil + expect(mutation_response['errors']).to match_array([ + "The resource that you are attempting to access does not "\ + "exist or you don't have permission to perform this action." + ]) + end + end + end + end + + context 'when deleting project upload' do + let_it_be_with_reload(:upload) { create(:upload, :issuable_upload, model: project) } + + let(:extra_params) { { project_path: project.full_path } } + + it_behaves_like 'upload deletion' + end + + context 'when deleting group upload' do + let_it_be_with_reload(:upload) { create(:upload, :namespace_upload, model: group) } + + let(:extra_params) { { group_path: group.full_path } } + + it_behaves_like 'upload deletion' + end +end diff --git a/spec/services/uploads/destroy_service_spec.rb b/spec/services/uploads/destroy_service_spec.rb new file mode 100644 index 0000000000000..bb58da231b6e3 --- /dev/null +++ b/spec/services/uploads/destroy_service_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Uploads::DestroyService do + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:upload) { create(:upload, :issuable_upload, model: project) } + + let(:filename) { File.basename(upload.path) } + let(:secret) { upload.secret } + let(:model) { project } + let(:service) { described_class.new(model, user) } + + describe '#execute' do + subject { service.execute(secret, filename) } + + shared_examples_for 'upload not found' do + it 'does not delete any upload' do + expect { subject }.not_to change { Upload.count } + end + + it 'returns an error' do + expect(subject[:status]).to eq(:error) + expect(subject[:message]).to eq("The resource that you are attempting to access does not "\ + "exist or you don't have permission to perform this action.") + end + end + + context 'when user is nil' do + let(:user) { nil } + + it_behaves_like 'upload not found' + end + + context 'when user cannot destroy upload' do + before do + project.add_developer(user) + end + + it_behaves_like 'upload not found' + end + + context 'when user can destroy upload' do + before do + project.add_maintainer(user) + end + + it 'deletes the upload' do + expect { subject }.to change { Upload.count }.by(-1) + end + + it 'returns success response' do + expect(subject[:status]).to eq(:success) + expect(subject[:upload]).to eq(upload) + end + + context 'when upload is not found' do + let(:filename) { 'not existing filename' } + + it_behaves_like 'upload not found' + end + + context 'when upload secret is not found' do + let(:secret) { 'aaaaaaaaaa' } + + it_behaves_like 'upload not found' + end + + context 'when upload secret has invalid format' do + let(:secret) { 'invalid' } + + it_behaves_like 'upload not found' + end + + context 'when unknown model is used' do + let(:model) { user } + + it 'raises an error' do + expect { subject }.to raise_exception(ArgumentError) + end + end + + context 'when upload belongs to other model' do + let_it_be(:upload) { create(:upload, :namespace_upload) } + + it_behaves_like 'upload not found' + end + + context 'when upload destroy fails' do + before do + allow(service).to receive(:find_upload).and_return(upload) + allow(upload).to receive(:destroy).and_return(false) + end + + it 'returns error' do + expect(subject[:status]).to eq(:error) + expect(subject[:message]).to eq('Upload could not be deleted.') + end + end + end + end +end diff --git a/spec/support/shared_contexts/policies/group_policy_shared_context.rb b/spec/support/shared_contexts/policies/group_policy_shared_context.rb index eec6e92c5fe09..893d370240793 100644 --- a/spec/support/shared_contexts/policies/group_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/group_policy_shared_context.rb @@ -56,6 +56,7 @@ admin_package create_projects create_cluster update_cluster admin_cluster add_cluster + destroy_upload ] end diff --git a/spec/support/shared_contexts/policies/project_policy_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_shared_context.rb index 789b385c43571..1d4731d9b390f 100644 --- a/spec/support/shared_contexts/policies/project_policy_shared_context.rb +++ b/spec/support/shared_contexts/policies/project_policy_shared_context.rb @@ -62,6 +62,7 @@ admin_project admin_project_member admin_snippet admin_terraform_state admin_wiki create_deploy_token destroy_deploy_token push_to_delete_protected_branch read_deploy_token update_snippet + destroy_upload ] end -- GitLab