diff options
24 files changed, 723 insertions, 221 deletions
diff --git a/GITLAB_KAS_VERSION b/GITLAB_KAS_VERSION index 188dd74f5f5..2e0b428c416 100644 --- a/GITLAB_KAS_VERSION +++ b/GITLAB_KAS_VERSION @@ -1 +1 @@ -15.5.0 +15.5.1 diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index c0e063a34d5..37adf4c2d3b 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -137,6 +137,19 @@ class GitlabSchema < GraphQL::Schema gid end + # Parse an array of strings to an array of GlobalIDs, raising ArgumentError if there are problems + # with it. + # See #parse_gid + # + # ``` + # gids = GitlabSchema.parse_gids(my_array_of_strings, expected_type: ::Project) + # project_ids = gids.map(&:model_id) + # gids.all? { |gid| gid.model_class == ::Project } + # ``` + def parse_gids(global_ids, ctx = {}) + global_ids.map { |gid| parse_gid(gid, ctx) } + end + private def max_query_complexity(ctx) diff --git a/app/graphql/mutations/packages/bulk_destroy.rb b/app/graphql/mutations/packages/bulk_destroy.rb new file mode 100644 index 00000000000..a0756d0c3f9 --- /dev/null +++ b/app/graphql/mutations/packages/bulk_destroy.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Mutations + module Packages + class BulkDestroy < ::Mutations::BaseMutation + graphql_name 'DestroyPackages' + + MAX_PACKAGES = 20 + TOO_MANY_IDS_ERROR = "Cannot delete more than #{MAX_PACKAGES} packages" + + argument :ids, + [::Types::GlobalIDType[::Packages::Package]], + required: true, + description: "Global IDs of the Packages. Max #{MAX_PACKAGES}" + + def resolve(ids:) + raise_resource_not_available_error!(TOO_MANY_IDS_ERROR) if ids.size > MAX_PACKAGES + + ids = GitlabSchema.parse_gids(ids, expected_type: ::Packages::Package) + .map(&:model_id) + + service = ::Packages::MarkPackagesForDestructionService.new( + packages: packages_from(ids), + current_user: current_user + ) + result = service.execute + + raise_resource_not_available_error! if result.reason == :unauthorized + + errors = result.error? ? Array.wrap(result[:message]) : [] + + { errors: errors } + end + + private + + def packages_from(ids) + ::Packages::Package.displayable + .id_in(ids) + end + end + end +end diff --git a/app/graphql/mutations/packages/destroy_files.rb b/app/graphql/mutations/packages/destroy_files.rb index 3900a2c46ae..60a21be20d8 100644 --- a/app/graphql/mutations/packages/destroy_files.rb +++ b/app/graphql/mutations/packages/destroy_files.rb @@ -25,7 +25,7 @@ module Mutations project = authorized_find!(project_path) raise_resource_not_available_error! "Cannot delete more than #{MAXIMUM_FILES} files" if ids.size > MAXIMUM_FILES - package_files = ::Packages::PackageFile.where(id: parse_gids(ids)) # rubocop:disable CodeReuse/ActiveRecord + package_files = ::Packages::PackageFile.id_in(parse_gids(ids)) ensure_file_access!(project, package_files) @@ -47,7 +47,7 @@ module Mutations end def parse_gids(gids) - gids.map { |gid| GitlabSchema.parse_gid(gid, expected_type: ::Packages::PackageFile).model_id } + GitlabSchema.parse_gids(gids, expected_type: ::Packages::PackageFile).map(&:model_id) end end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 109152a2f8a..5ffc1aeacad 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -138,6 +138,8 @@ module Types mount_mutation Mutations::UserCallouts::Create mount_mutation Mutations::UserPreferences::Update mount_mutation Mutations::Packages::Destroy + mount_mutation Mutations::Packages::BulkDestroy, + extensions: [::Gitlab::Graphql::Limit::FieldCallCount => { limit: 1 }] mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Packages::DestroyFiles mount_mutation Mutations::Packages::Cleanup::Policy::Update diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index b7c784d9351..a1d2a47c392 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -124,6 +124,7 @@ class Packages::Package < ApplicationRecord scope :with_package_type, ->(package_type) { where(package_type: package_type) } scope :without_package_type, ->(package_type) { where.not(package_type: package_type) } scope :displayable, -> { with_status(DISPLAYABLE_STATUSES) } + scope :including_project_full_path, -> { includes(project: :route) } scope :including_project_route, -> { includes(project: { namespace: :route }) } scope :including_tags, -> { includes(:tags) } scope :including_dependency_links, -> { includes(dependency_links: :dependency) } diff --git a/app/services/packages/mark_packages_for_destruction_service.rb b/app/services/packages/mark_packages_for_destruction_service.rb new file mode 100644 index 00000000000..856e14f9fd3 --- /dev/null +++ b/app/services/packages/mark_packages_for_destruction_service.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Packages + class MarkPackagesForDestructionService + include BaseServiceUtility + + BATCH_SIZE = 20 + + UNAUTHORIZED_RESPONSE = ServiceResponse.error( + message: "You don't have the permission to perform this action", + reason: :unauthorized + ).freeze + + ERROR_RESPONSE = ServiceResponse.error( + message: 'Failed to mark the packages as pending destruction' + ).freeze + + SUCCESS_RESPONSE = ServiceResponse.success( + message: 'Packages were successfully marked as pending destruction' + ).freeze + + # Initialize this service with the given packages and user. + # + # * `packages`: must be an ActiveRecord relationship. + # * `current_user`: an User object. Could be nil. + def initialize(packages:, current_user: nil) + @packages = packages + @current_user = current_user + end + + def execute(batch_size: BATCH_SIZE) + no_access = false + min_batch_size = [batch_size, BATCH_SIZE].min + + @packages.each_batch(of: min_batch_size) do |batched_packages| + loaded_packages = batched_packages.including_project_full_path.to_a + + break no_access = true unless can_destroy_packages?(loaded_packages) + + ::Packages::Package.id_in(loaded_packages.map(&:id)) + .update_all(status: :pending_destruction) + + sync_maven_metadata(loaded_packages) + mark_package_files_for_destruction(loaded_packages) + end + + return UNAUTHORIZED_RESPONSE if no_access + + SUCCESS_RESPONSE + rescue StandardError + ERROR_RESPONSE + end + + private + + def mark_package_files_for_destruction(packages) + ::Packages::MarkPackageFilesForDestructionWorker.bulk_perform_async_with_contexts( + packages, + arguments_proc: -> (package) { package.id }, + context_proc: -> (package) { { project: package.project, user: @current_user } } + ) + end + + def sync_maven_metadata(packages) + maven_packages_with_version = packages.select { |pkg| pkg.maven? && pkg.version? } + ::Packages::Maven::Metadata::SyncWorker.bulk_perform_async_with_contexts( + maven_packages_with_version, + arguments_proc: -> (package) { [@current_user.id, package.project_id, package.name] }, + context_proc: -> (package) { { project: package.project, user: @current_user } } + ) + end + + def can_destroy_packages?(packages) + packages.all? do |package| + can?(@current_user, :destroy_package, package) + end + end + end +end diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3f89ff08110..41d578bb95f 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2426,6 +2426,24 @@ Input type: `DestroyPackageFilesInput` | <a id="mutationdestroypackagefilesclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationdestroypackagefileserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | +### `Mutation.destroyPackages` + +Input type: `DestroyPackagesInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationdestroypackagesclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationdestroypackagesids"></a>`ids` | [`[PackagesPackageID!]!`](#packagespackageid) | Global IDs of the Packages. Max 20. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationdestroypackagesclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationdestroypackageserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.destroySnippet` Input type: `DestroySnippetInput` diff --git a/doc/user/profile/img/unknown_sign_in_email_v14_0.png b/doc/user/profile/img/unknown_sign_in_email_v14_0.png Binary files differdeleted file mode 100644 index 62634739b78..00000000000 --- a/doc/user/profile/img/unknown_sign_in_email_v14_0.png +++ /dev/null diff --git a/doc/user/profile/img/wrong_two_factor_authentication_code_v15_5.png b/doc/user/profile/img/wrong_two_factor_authentication_code_v15_5.png Binary files differdeleted file mode 100644 index 2774702f003..00000000000 --- a/doc/user/profile/img/wrong_two_factor_authentication_code_v15_5.png +++ /dev/null diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md index ade5f912c93..66f6b4b52de 100644 --- a/doc/user/profile/index.md +++ b/doc/user/profile/index.md @@ -463,8 +463,9 @@ Without the `config.extend_remember_period` flag, you would be forced to sign in - [Create users](account/create_accounts.md) - [Sign in to your GitLab account](../../topics/authentication/index.md) - [Change your password](user_passwords.md) -- [Receive emails for sign-ins from unknown IP addresses or devices](unknown_sign_in_notification.md) -- [Receive emails for attempted sign-ins using a wrong two-factor authentication code](wrong_two_factor_authentication_code_notification.md) +- Receive emails for: + - [Sign-ins from unknown IP addresses or devices](notifications.md#notifications-for-unknown-sign-ins) + - [Attempted sign-ins using wrong two-factor authentication codes](notifications.md#notifications-for-attempted-sign-in-using-wrong-two-factor-authentication-codes) - Manage applications that can [use GitLab as an OAuth provider](../../integration/oauth_provider.md#introduction-to-oauth) - Manage [personal access tokens](personal_access_tokens.md) to access your account via API and authorized applications - Manage [SSH keys](../ssh.md) to access your account via SSH diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md index f66cf9fd0e1..7942ee38be9 100644 --- a/doc/user/profile/notifications.md +++ b/doc/user/profile/notifications.md @@ -286,6 +286,36 @@ By default, you don't receive notifications for issues, merge requests, or epics To always receive notifications on your own issues, merge requests, and so on, turn on [notifications about your own activity](#global-notification-settings). +## Notifications for unknown sign-ins + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/27211) in GitLab 13.0. + +NOTE: +This feature is enabled by default for self-managed instances. Administrators may disable this feature +through the [Sign-in restrictions](../admin_area/settings/sign_in_restrictions.md#email-notification-for-unknown-sign-ins) section of the UI. +The feature is always enabled on GitLab.com. + +When a user successfully signs in from a previously unknown IP address or device, +GitLab notifies the user by email. In this way, GitLab proactively alerts users of potentially +malicious or unauthorized sign-ins. + +GitLab uses several methods to identify a known sign-in. All methods must fail for a notification email to be sent. + +- Last sign-in IP: The current sign-in IP address is checked against the last sign-in + IP address. +- Current active sessions: If the user has an existing active session from the + same IP address. See [Active Sessions](active_sessions.md). +- Cookie: After successful sign in, an encrypted cookie is stored in the browser. + This cookie is set to expire 14 days after the last successful sign in. + +## Notifications for attempted sign-in using wrong two-factor authentication codes + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/374740) in GitLab 15.5. + +GitLab sends you an email notification if it detects an attempt to sign in to your account using a wrong two-factor +authentication (2FA) code. This can help you detect that a bad actor gained access to your username and password, and is trying +to brute force 2FA. + ## Notifications on designs > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217095) in GitLab 13.6. diff --git a/doc/user/profile/unknown_sign_in_notification.md b/doc/user/profile/unknown_sign_in_notification.md index 731526bd61f..3bdcd36a34e 100644 --- a/doc/user/profile/unknown_sign_in_notification.md +++ b/doc/user/profile/unknown_sign_in_notification.md @@ -1,32 +1,11 @@ --- -stage: Manage -group: Authentication and Authorization -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +redirect_to: 'notifications.md' +remove_date: '2023-01-15' --- -# Email notification for unknown sign-ins **(FREE)** +This document was moved to [another location](notifications.md). -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/27211) in GitLab 13.0. - -NOTE: -This feature is enabled by default for self-managed instances. Administrators may disable this feature -through the [Sign-in restrictions](../admin_area/settings/sign_in_restrictions.md#email-notification-for-unknown-sign-ins) section of the UI. -The feature is always enabled on GitLab.com. - -When a user successfully signs in from a previously unknown IP address or device, -GitLab notifies the user by email. In this way, GitLab proactively alerts users of potentially -malicious or unauthorized sign-ins. - -There are several methods used to identify a known sign-in. All methods must fail -for a notification email to be sent. - -- Last sign-in IP: The current sign-in IP address is checked against the last sign-in - IP address. -- Current active sessions: If the user has an existing active session from the - same IP address. See [Active Sessions](active_sessions.md). -- Cookie: After successful sign in, an encrypted cookie is stored in the browser. - This cookie is set to expire 14 days after the last successful sign in. - -## Example notification email - -![Unknown sign in email](img/unknown_sign_in_email_v14_0.png) +<!-- This redirect file can be deleted after <2023-01-15>. --> +<!-- Redirects that point to other docs in the same project expire in three months. --> +<!-- Redirects that point to docs in a different project or site (for example, link is not relative and starts with `https:`) expire in one year. --> +<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html --> diff --git a/doc/user/profile/wrong_two_factor_authentication_code_notification.md b/doc/user/profile/wrong_two_factor_authentication_code_notification.md deleted file mode 100644 index 787830e7614..00000000000 --- a/doc/user/profile/wrong_two_factor_authentication_code_notification.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -stage: Manage -group: Authentication and Authorization -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments ---- - -# Email notification for attempted sign-in using wrong two-factor authentication code **(FREE)** - -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/374740) in GitLab 15.5. - -GitLab sends you an email notification if it detects an attempt to sign in to your account using a wrong two-factor authentication code. This way, GitLab proactively alerts you of potentially malicious or unauthorized sign-ins, in case a bad actor gained access to your username and password, and is trying to bruteforce two-factor authentication. - -## Example notification email - -![Incorrect two-factor code email](img/wrong_two_factor_authentication_code_v15_5.png) diff --git a/lib/gitlab/ci/templates/Elixir.gitlab-ci.yml b/lib/gitlab/ci/templates/Elixir.gitlab-ci.yml index 83ddce936e6..8b20c4cbccc 100644 --- a/lib/gitlab/ci/templates/Elixir.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Elixir.gitlab-ci.yml @@ -24,3 +24,8 @@ before_script: mix: script: - mix test + +deploy: + stage: deploy + script: echo "Define your deployment script!" + environment: production diff --git a/lib/gitlab/ci/templates/Flutter.gitlab-ci.yml b/lib/gitlab/ci/templates/Flutter.gitlab-ci.yml index 021662ab416..7f81755348c 100644 --- a/lib/gitlab/ci/templates/Flutter.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Flutter.gitlab-ci.yml @@ -35,3 +35,8 @@ test: - $CI_PROJECT_DIR/coverage reports: junit: report.xml + +deploy: + stage: deploy + script: echo "Define your deployment script!" + environment: production diff --git a/lib/gitlab/ci/templates/Go.gitlab-ci.yml b/lib/gitlab/ci/templates/Go.gitlab-ci.yml index 603aede4d46..8cfea3e236f 100644 --- a/lib/gitlab/ci/templates/Go.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Go.gitlab-ci.yml @@ -28,3 +28,8 @@ compile: artifacts: paths: - mybinaries + +deploy: + stage: deploy + script: echo "Define your deployment script!" + environment: production diff --git a/lib/gitlab/ci/templates/Gradle.gitlab-ci.yml b/lib/gitlab/ci/templates/Gradle.gitlab-ci.yml index 08dc10d34b7..671925c5df6 100644 --- a/lib/gitlab/ci/templates/Gradle.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Gradle.gitlab-ci.yml @@ -1,3 +1,5 @@ +# You can copy and paste this template into a new `.gitlab-ci.yml` file. +# You should not add this template to an existing `.gitlab-ci.yml` file by using the `include:` keyword. # To contribute improvements to CI/CD templates, please follow the Development guide at: # https://docs.gitlab.com/ee/development/cicd/templates.html # This specific template is located at: @@ -39,3 +41,8 @@ test: paths: - build - .gradle + +deploy: + stage: deploy + script: echo "Define your deployment script!" + environment: production diff --git a/lib/gitlab/ci/templates/Grails.gitlab-ci.yml b/lib/gitlab/ci/templates/Grails.gitlab-ci.yml index 03c8941169f..01697f67b89 100644 --- a/lib/gitlab/ci/templates/Grails.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Grails.gitlab-ci.yml @@ -46,3 +46,8 @@ before_script: build: script: - ./gradlew build + +deploy: + stage: deploy + script: echo "Define your deployment script!" + environment: production diff --git a/spec/graphql/gitlab_schema_spec.rb b/spec/graphql/gitlab_schema_spec.rb index 60b3edfc279..517897311e1 100644 --- a/spec/graphql/gitlab_schema_spec.rb +++ b/spec/graphql/gitlab_schema_spec.rb @@ -232,11 +232,7 @@ RSpec.describe GitlabSchema do end end - describe '.parse_gid' do - let_it_be(:global_id) { 'gid://gitlab/TestOne/2147483647' } - - subject(:parse_gid) { described_class.parse_gid(global_id) } - + context 'for gid parsing' do before do test_base = Class.new test_one = Class.new(test_base) @@ -249,66 +245,85 @@ RSpec.describe GitlabSchema do stub_const('TestThree', test_three) end - it 'parses the gid' do - gid = parse_gid + describe '.parse_gid' do + let_it_be(:global_id) { 'gid://gitlab/TestOne/2147483647' } - expect(gid.model_id).to eq '2147483647' - expect(gid.model_class).to eq TestOne - end + subject(:parse_gid) { described_class.parse_gid(global_id) } - context 'when gid is malformed' do - let_it_be(:global_id) { 'malformed://gitlab/TestOne/2147483647' } + it 'parses the gid' do + gid = parse_gid - it 'raises an error' do - expect { parse_gid } - .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab ID.") + expect(gid.model_id).to eq '2147483647' + expect(gid.model_class).to eq TestOne end - end - context 'when using expected_type' do - it 'accepts a single type' do - gid = described_class.parse_gid(global_id, expected_type: TestOne) + context 'when gid is malformed' do + let_it_be(:global_id) { 'malformed://gitlab/TestOne/2147483647' } - expect(gid.model_class).to eq TestOne + it 'raises an error' do + expect { parse_gid } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab ID.") + end end - it 'accepts an ancestor type' do - gid = described_class.parse_gid(global_id, expected_type: TestBase) + context 'when using expected_type' do + it 'accepts a single type' do + gid = described_class.parse_gid(global_id, expected_type: TestOne) - expect(gid.model_class).to eq TestOne - end + expect(gid.model_class).to eq TestOne + end - it 'rejects an unknown type' do - expect { described_class.parse_gid(global_id, expected_type: TestTwo) } - .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid ID for TestTwo.") - end + it 'accepts an ancestor type' do + gid = described_class.parse_gid(global_id, expected_type: TestBase) - context 'when expected_type is an array' do - subject(:parse_gid) { described_class.parse_gid(global_id, expected_type: [TestOne, TestTwo]) } + expect(gid.model_class).to eq TestOne + end - context 'when global_id is of type TestOne' do - it 'returns an object of an expected type' do - expect(parse_gid.model_class).to eq TestOne - end + it 'rejects an unknown type' do + expect { described_class.parse_gid(global_id, expected_type: TestTwo) } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid ID for TestTwo.") end - context 'when global_id is of type TestTwo' do - let_it_be(:global_id) { 'gid://gitlab/TestTwo/2147483647' } + context 'when expected_type is an array' do + subject(:parse_gid) { described_class.parse_gid(global_id, expected_type: [TestOne, TestTwo]) } - it 'returns an object of an expected type' do - expect(parse_gid.model_class).to eq TestTwo + context 'when global_id is of type TestOne' do + it 'returns an object of an expected type' do + expect(parse_gid.model_class).to eq TestOne + end + end + + context 'when global_id is of type TestTwo' do + let_it_be(:global_id) { 'gid://gitlab/TestTwo/2147483647' } + + it 'returns an object of an expected type' do + expect(parse_gid.model_class).to eq TestTwo + end end - end - context 'when global_id is of type TestThree' do - let_it_be(:global_id) { 'gid://gitlab/TestThree/2147483647' } + context 'when global_id is of type TestThree' do + let_it_be(:global_id) { 'gid://gitlab/TestThree/2147483647' } - it 'rejects an unknown type' do - expect { parse_gid } - .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid ID for TestOne, TestTwo.") + it 'rejects an unknown type' do + expect { parse_gid } + .to raise_error(Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid ID for TestOne, TestTwo.") + end end end end end + + describe '.parse_gids' do + let_it_be(:global_ids) { %w[gid://gitlab/TestOne/123 gid://gitlab/TestOne/456] } + + subject(:parse_gids) { described_class.parse_gids(global_ids, expected_type: TestOne) } + + it 'parses the gids' do + expect(described_class).to receive(:parse_gid).with('gid://gitlab/TestOne/123', expected_type: TestOne).and_call_original + expect(described_class).to receive(:parse_gid).with('gid://gitlab/TestOne/456', expected_type: TestOne).and_call_original + expect(parse_gids.map(&:model_id)).to eq %w[123 456] + expect(parse_gids.map(&:model_class)).to match_array [TestOne, TestOne] + end + end end end diff --git a/spec/policies/project_snippet_policy_spec.rb b/spec/policies/project_snippet_policy_spec.rb index 8b96aa99f69..c6d8ef05cfd 100644 --- a/spec/policies/project_snippet_policy_spec.rb +++ b/spec/policies/project_snippet_policy_spec.rb @@ -2,29 +2,28 @@ require 'spec_helper' -# Snippet visibility scenarios are included in more details in spec/support/snippet_visibility.rb +# Snippet visibility scenarios are included in more details in spec/finders/snippets_finder_spec.rb RSpec.describe ProjectSnippetPolicy do + let_it_be(:group) { create(:group, :public) } let_it_be(:regular_user) { create(:user) } - let_it_be(:other_user) { create(:user) } let_it_be(:external_user) { create(:user, :external) } - let_it_be(:project) { create(:project, :public) } - - let(:snippet) { create(:project_snippet, snippet_visibility, project: project, author: author) } - let(:author) { other_user } - let(:author_permissions) do + let_it_be(:author) { create(:user) } + let_it_be(:author_permissions) do [ :update_snippet, :admin_snippet ] end + let(:snippet) { build(:project_snippet, snippet_visibility, project: project, author: author) } + subject { described_class.new(current_user, snippet) } - shared_examples 'regular user access rights' do + shared_examples 'regular user member permissions' do context 'not snippet author' do - context 'project team member (non guest)' do + context 'member (guest)' do before do - project.add_developer(current_user) + membership_target.add_guest(current_user) end it do @@ -33,25 +32,35 @@ RSpec.describe ProjectSnippetPolicy do end end - context 'project team member (guest)' do + context 'member (reporter)' do before do - project.add_guest(current_user) + membership_target.add_reporter(current_user) end it do expect_allowed(:read_snippet, :create_note) - expect_disallowed(:admin_snippet) + expect_disallowed(*author_permissions) end end - context 'project team member (maintainer)' do + context 'member (developer)' do before do - project.add_maintainer(current_user) + membership_target.add_developer(current_user) end it do expect_allowed(:read_snippet, :create_note) - expect_allowed(*author_permissions) + expect_disallowed(*author_permissions) + end + end + + context 'member (maintainer)' do + before do + membership_target.add_maintainer(current_user) + end + + it do + expect_allowed(:read_snippet, :create_note, *author_permissions) end end end @@ -59,196 +68,263 @@ RSpec.describe ProjectSnippetPolicy do context 'snippet author' do let(:author) { current_user } - context 'project member (non guest)' do + context 'member (guest)' do before do - project.add_developer(current_user) + membership_target.add_guest(current_user) end it do - expect_allowed(:read_snippet, :create_note) - expect_allowed(*author_permissions) + expect_allowed(:read_snippet, :create_note, :update_snippet) + expect_disallowed(:admin_snippet) end end - context 'project member (guest)' do + context 'member (reporter)' do before do - project.add_guest(current_user) + membership_target.add_reporter(current_user) end it do - expect_allowed(:read_snippet, :create_note) - expect_disallowed(:admin_snippet) + expect_allowed(:read_snippet, :create_note, *author_permissions) end end - context 'project team member (maintainer)' do + context 'member (developer)' do before do - project.add_maintainer(current_user) + membership_target.add_developer(current_user) end it do - expect_allowed(:read_snippet, :create_note) - expect_allowed(*author_permissions) + expect_allowed(:read_snippet, :create_note, *author_permissions) end end - context 'not a project member' do + context 'member (maintainer)' do + before do + membership_target.add_maintainer(current_user) + end + it do - expect_allowed(:read_snippet, :create_note) - expect_disallowed(:admin_snippet) + expect_allowed(:read_snippet, :create_note, *author_permissions) end end end end - context 'public snippet' do - let(:snippet_visibility) { :public } - - context 'no user' do - let(:current_user) { nil } + shared_examples 'regular user non-member author permissions' do + let(:author) { current_user } - it do - expect_allowed(:read_snippet) - expect_disallowed(*author_permissions) - end + it do + expect_allowed(:read_snippet, :create_note, :update_snippet) + expect_disallowed(:admin_snippet) end + end - context 'regular user' do - let(:current_user) { regular_user } - - it do - expect_allowed(:read_snippet, :create_note) - expect_disallowed(*author_permissions) - end + context 'when project is public' do + let_it_be(:project) { create(:project, :public, group: group) } - it_behaves_like 'regular user access rights' - end + context 'with public snippet' do + let(:snippet_visibility) { :public } - context 'external user' do - let(:current_user) { external_user } + context 'no user' do + let(:current_user) { nil } - it do - expect_allowed(:read_snippet, :create_note) - expect_disallowed(*author_permissions) + it do + expect_allowed(:read_snippet) + expect_disallowed(*author_permissions) + end end - context 'project team member' do - before do - project.add_developer(external_user) + context 'regular user' do + let(:current_user) { regular_user } + let(:membership_target) { project } + + context 'when user is not a member' do + context 'and is not the snippet author' do + it do + expect_allowed(:read_snippet, :create_note) + expect_disallowed(*author_permissions) + end + end + + context 'and is the snippet author' do + it_behaves_like 'regular user non-member author permissions' + end end + context 'when user is a member' do + it_behaves_like 'regular user member permissions' + end + end + + context 'external user' do + let(:current_user) { external_user } + it do expect_allowed(:read_snippet, :create_note) expect_disallowed(*author_permissions) end - end - end - end - - context 'internal snippet' do - let(:snippet_visibility) { :internal } - context 'no user' do - let(:current_user) { nil } + context 'when user is a member' do + before do + project.add_developer(external_user) + end - it do - expect_disallowed(:read_snippet) - expect_disallowed(*author_permissions) + it do + expect_allowed(:read_snippet, :create_note) + expect_disallowed(*author_permissions) + end + end end end - context 'regular user' do - let(:current_user) { regular_user } + context 'with internal snippet' do + let(:snippet_visibility) { :internal } - it do - expect_allowed(:read_snippet, :create_note) - expect_disallowed(*author_permissions) - end + context 'no user' do + let(:current_user) { nil } - it_behaves_like 'regular user access rights' - end + it do + expect_disallowed(:read_snippet) + expect_disallowed(*author_permissions) + end + end - context 'external user' do - let(:current_user) { external_user } + context 'regular user' do + let(:current_user) { regular_user } + let(:membership_target) { project } + + context 'when user is not a member' do + context 'and is not the snippet author' do + it do + expect_allowed(:read_snippet, :create_note) + expect_disallowed(*author_permissions) + end + end + + context 'and is the snippet author' do + it_behaves_like 'regular user non-member author permissions' + end + end - it do - expect_disallowed(:read_snippet, :create_note) - expect_disallowed(*author_permissions) + context 'when user is a member' do + it_behaves_like 'regular user member permissions' + end end - context 'project team member' do - before do - project.add_developer(external_user) - end + context 'external user' do + let(:current_user) { external_user } it do - expect_allowed(:read_snippet, :create_note) + expect_disallowed(:read_snippet, :create_note) expect_disallowed(*author_permissions) end + + context 'when user is a member' do + before do + project.add_developer(external_user) + end + + it do + expect_allowed(:read_snippet, :create_note) + expect_disallowed(*author_permissions) + end + end end end - end - context 'private snippet' do - let(:snippet_visibility) { :private } + context 'with private snippet' do + let(:snippet_visibility) { :private } - context 'no user' do - let(:current_user) { nil } + context 'no user' do + let(:current_user) { nil } - it do - expect_disallowed(:read_snippet) - expect_disallowed(*author_permissions) + it do + expect_disallowed(:read_snippet) + expect_disallowed(*author_permissions) + end end - end - context 'regular user' do - let(:current_user) { regular_user } + context 'regular user' do + let(:current_user) { regular_user } + let(:membership_target) { project } + + context 'when user is not a member' do + context 'and is not the snippet author' do + it do + expect_disallowed(:read_snippet, :create_note) + expect_disallowed(*author_permissions) + end + end + + context 'and is the snippet author' do + it_behaves_like 'regular user non-member author permissions' + end + end - it do - expect_disallowed(:read_snippet, :create_note) - expect_disallowed(*author_permissions) + context 'when user is a member' do + it_behaves_like 'regular user member permissions' + end end - it_behaves_like 'regular user access rights' - end - - context 'external user' do - let(:current_user) { external_user } + context 'inherited user' do + let(:current_user) { regular_user } + let(:membership_target) { group } - it do - expect_disallowed(:read_snippet, :create_note) - expect_disallowed(*author_permissions) + it_behaves_like 'regular user member permissions' end - context 'project team member' do - before do - project.add_developer(current_user) - end + context 'external user' do + let(:current_user) { external_user } it do - expect_allowed(:read_snippet, :create_note) + expect_disallowed(:read_snippet, :create_note) expect_disallowed(*author_permissions) end - end - end - context 'admin user' do - let(:snippet_visibility) { :private } - let(:current_user) { create(:admin) } + context 'when user is a member' do + before do + project.add_developer(current_user) + end - context 'when admin mode is enabled', :enable_admin_mode do - it do - expect_allowed(:read_snippet, :create_note) - expect_allowed(*author_permissions) + it do + expect_allowed(:read_snippet, :create_note) + expect_disallowed(*author_permissions) + end end end - context 'when admin mode is disabled' do - it do - expect_disallowed(:read_snippet, :create_note) - expect_disallowed(*author_permissions) + context 'admin user' do + let(:snippet_visibility) { :private } + let(:current_user) { create(:admin) } + + context 'when admin mode is enabled', :enable_admin_mode do + it do + expect_allowed(:read_snippet, :create_note) + expect_allowed(*author_permissions) + end + end + + context 'when admin mode is disabled' do + it do + expect_disallowed(:read_snippet, :create_note) + expect_disallowed(*author_permissions) + end end end end end + + context 'when project is private' do + let_it_be(:project) { create(:project, :private, group: group) } + + let(:snippet_visibility) { :private } + + context 'inherited user' do + let(:current_user) { regular_user } + let(:membership_target) { group } + + it_behaves_like 'regular user member permissions' + end + end end diff --git a/spec/requests/api/graphql/mutations/packages/bulk_destroy_spec.rb b/spec/requests/api/graphql/mutations/packages/bulk_destroy_spec.rb new file mode 100644 index 00000000000..1fe01af4f1c --- /dev/null +++ b/spec/requests/api/graphql/mutations/packages/bulk_destroy_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Destroying multiple packages' do + using RSpec::Parameterized::TableSyntax + + include GraphqlHelpers + + let_it_be(:project1) { create(:project) } + let_it_be(:project2) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:packages1) { create_list(:package, 3, project: project1) } + let_it_be_with_reload(:packages2) { create_list(:package, 2, project: project2) } + + let(:ids) { packages1.append(packages2).flatten.map(&:to_global_id).map(&:to_s) } + + let(:query) do + <<~GQL + errors + GQL + end + + let(:params) do + { + ids: ids + } + end + + let(:mutation) { graphql_mutation(:destroy_packages, params, query) } + + describe 'post graphql mutation' do + subject(:mutation_request) { post_graphql_mutation(mutation, current_user: user) } + + shared_examples 'destroying the packages' do + it 'marks the packages as pending destruction' do + expect { mutation_request }.to change { ::Packages::Package.pending_destruction.count }.by(5) + end + + it_behaves_like 'returning response status', :success + end + + shared_examples 'denying the mutation request' do + |response = ::Gitlab::Graphql::Authorize::AuthorizeResource::RESOURCE_ACCESS_ERROR| + it 'does not mark the packages as pending destruction' do + expect { mutation_request }.not_to change { ::Packages::Package.pending_destruction.count } + expect_graphql_errors_to_include(response) + end + + it_behaves_like 'returning response status', :success + end + + context 'with valid params' do + where(:user_role, :shared_examples_name) do + :maintainer | 'destroying the packages' + :developer | 'denying the mutation request' + :reporter | 'denying the mutation request' + :guest | 'denying the mutation request' + :not_in_project | 'denying the mutation request' + end + + with_them do + before do + unless user_role == :not_in_project + project1.send("add_#{user_role}", user) + project2.send("add_#{user_role}", user) + end + end + + it_behaves_like params[:shared_examples_name] + end + + context 'for over the limit' do + before do + project1.add_maintainer(user) + project2.add_maintainer(user) + stub_const("Mutations::Packages::BulkDestroy::MAX_PACKAGES", 2) + end + + it_behaves_like 'denying the mutation request', ::Mutations::Packages::BulkDestroy::TOO_MANY_IDS_ERROR + end + + context 'with packages outside of the project' do + before do + project1.add_maintainer(user) + end + + it_behaves_like 'denying the mutation request' + end + end + + context 'with invalid params' do + let(:ids) { 'foo' } + + it_behaves_like 'denying the mutation request', 'invalid value for id' + end + + context 'with multi mutations' do + let(:package1) { packages1.first } + let(:package2) { packages2.first } + let(:query) do + <<~QUERY + mutation { + a: destroyPackages(input: { ids: ["#{package1.to_global_id}"]}) { + errors + } + b: destroyPackages(input: { ids: ["#{package2.to_global_id}"]}) { + errors + } + } + QUERY + end + + subject(:mutation_request) { post_graphql(query, current_user: user) } + + before do + project1.add_maintainer(user) + project2.add_maintainer(user) + end + + it 'executes the first mutation but not the second one' do + expect { mutation_request }.to change { package1.reload.status }.from('default').to('pending_destruction') + .and not_change { package2.reload.status } + expect_graphql_errors_to_include('"destroyPackages" field can be requested only for 1 Mutation(s) at a time.') + end + end + end +end diff --git a/spec/services/packages/mark_packages_for_destruction_service_spec.rb b/spec/services/packages/mark_packages_for_destruction_service_spec.rb new file mode 100644 index 00000000000..5c043b89de8 --- /dev/null +++ b/spec/services/packages/mark_packages_for_destruction_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::MarkPackagesForDestructionService, :sidekiq_inline do + let_it_be(:project) { create(:project) } + let_it_be_with_reload(:packages) { create_list(:npm_package, 3, project: project) } + + let(:user) { project.owner } + + # The service only accepts ActiveRecord relationships and not arrays. + let(:service) { described_class.new(packages: ::Packages::Package.id_in(package_ids), current_user: user) } + let(:package_ids) { packages.map(&:id) } + + describe '#execute' do + subject { service.execute } + + context 'when the user is authorized' do + before do + project.add_maintainer(user) + end + + context 'when it is successful' do + it 'marks the packages as pending destruction' do + expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new) + + expect { subject }.to change { ::Packages::Package.pending_destruction.count }.from(0).to(3) + .and change { Packages::PackageFile.pending_destruction.count }.from(0).to(3) + packages.each { |pkg| expect(pkg.reload).to be_pending_destruction } + + expect(subject).to be_a(ServiceResponse) + expect(subject).to be_success + expect(subject.message).to eq('Packages were successfully marked as pending destruction') + end + + context 'with maven packages' do + let_it_be_with_reload(:packages) { create_list(:maven_package, 3, project: project) } + + it 'marks the packages as pending destruction' do + expect(::Packages::Maven::Metadata::SyncService).to receive(:new).once.and_call_original + + expect { subject }.to change { ::Packages::Package.pending_destruction.count }.from(0).to(3) + .and change { Packages::PackageFile.pending_destruction.count }.from(0).to(9) + packages.each { |pkg| expect(pkg.reload).to be_pending_destruction } + + expect(subject).to be_a(ServiceResponse) + expect(subject).to be_success + expect(subject.message).to eq('Packages were successfully marked as pending destruction') + end + + context 'without version' do + before do + ::Packages::Package.id_in(package_ids).update_all(version: nil) + end + + it 'marks the packages as pending destruction' do + expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new) + + expect { subject }.to change { ::Packages::Package.pending_destruction.count }.from(0).to(3) + .and change { Packages::PackageFile.pending_destruction.count }.from(0).to(9) + packages.each { |pkg| expect(pkg.reload).to be_pending_destruction } + + expect(subject).to be_a(ServiceResponse) + expect(subject).to be_success + expect(subject.message).to eq('Packages were successfully marked as pending destruction') + end + end + end + end + + context 'when it is not successful' do + before do + allow(service).to receive(:can_destroy_packages?).and_raise(StandardError, 'test') + end + + it 'returns an error ServiceResponse' do + expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new) + + expect { subject }.to not_change { ::Packages::Package.pending_destruction.count } + .and not_change { ::Packages::PackageFile.pending_destruction.count } + + expect(subject).to be_a(ServiceResponse) + expect(subject).to be_error + expect(subject.message).to eq("Failed to mark the packages as pending destruction") + expect(subject.status).to eq(:error) + end + end + end + + context 'when the user is not authorized' do + let(:user) { nil } + + it 'returns an error ServiceResponse' do + expect(::Packages::Maven::Metadata::SyncService).not_to receive(:new) + + expect { subject }.to not_change { ::Packages::Package.pending_destruction.count } + .and not_change { ::Packages::PackageFile.pending_destruction.count } + + expect(subject).to be_a(ServiceResponse) + expect(subject).to be_error + expect(subject.message).to eq("You don't have the permission to perform this action") + expect(subject.status).to eq(:error) + expect(subject.reason).to eq(:unauthorized) + end + end + end +end diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index e78b696268f..85dee935001 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -115,9 +115,7 @@ RSpec.describe RepositoryForkWorker do context 'project ID, storage and repo paths passed' do def perform! - Gitlab::GitalyClient::StorageSettings.allow_disk_access do - subject.perform(forked_project.id, TestEnv.repos_path, project.disk_path) - end + subject.perform(forked_project.id, 'repos/path', project.disk_path) end it_behaves_like 'RepositoryForkWorker performing' |