diff --git a/app/graphql/mutations/packages/protection/rule/create.rb b/app/graphql/mutations/packages/protection/rule/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..2b8b18c5b3004317277563fb841de8523745e02c --- /dev/null +++ b/app/graphql/mutations/packages/protection/rule/create.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Mutations + module Packages + module Protection + module Rule + class Create < ::Mutations::BaseMutation + graphql_name 'CreatePackagesProtectionRule' + description 'Creates a protection rule to restrict access to project packages. ' \ + 'Available only when feature flag `packages_protected_packages` is enabled.' + + include FindsProject + + authorize :admin_package + + argument :project_path, + GraphQL::Types::ID, + required: true, + description: 'Full path of the project where a protection rule is located.' + + argument :package_name_pattern, + GraphQL::Types::String, + required: true, + description: + 'Package name protected by the protection rule. For example `@my-scope/my-package-*`. ' \ + 'Wildcard character `*` allowed.' + + argument :package_type, + Types::Packages::Protection::RulePackageTypeEnum, + required: true, + description: 'Package type protected by the protection rule. For example `NPM`.' + + argument :push_protected_up_to_access_level, + Types::Packages::Protection::RuleAccessLevelEnum, + required: true, + description: + 'Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`.' + + field :package_protection_rule, + Types::Packages::Protection::RuleType, + null: true, + description: 'Packages protection rule after mutation.' + + def resolve(project_path:, **kwargs) + project = authorized_find!(project_path) + + if Feature.disabled?(:packages_protected_packages, project) + raise_resource_not_available_error!("'packages_protected_packages' feature flag is disabled") + end + + response = ::Packages::Protection::CreateRuleService.new(project, current_user, kwargs).execute + + { package_protection_rule: response.payload[:package_protection_rule], errors: response.errors } + end + end + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 9d74ca1342410fdda6197d0e68bc43d1d6383ee5..3af7140aed30d2aa85cd9fe437c4e365fca73e1e 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -170,6 +170,7 @@ class MutationType < BaseObject mount_mutation Mutations::Packages::BulkDestroy, extensions: [::Gitlab::Graphql::Limit::FieldCallCount => { limit: 1 }] mount_mutation Mutations::Packages::DestroyFile + mount_mutation Mutations::Packages::Protection::Rule::Create, alpha: { milestone: '16.5' } mount_mutation Mutations::Packages::DestroyFiles mount_mutation Mutations::Packages::Cleanup::Policy::Update mount_mutation Mutations::Echo diff --git a/app/graphql/types/packages/protection/rule_access_level_enum.rb b/app/graphql/types/packages/protection/rule_access_level_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..fbc19847bcc6a6860a686c14fb4dad2d8fb2ce18 --- /dev/null +++ b/app/graphql/types/packages/protection/rule_access_level_enum.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Packages + module Protection + class RuleAccessLevelEnum < BaseEnum + graphql_name 'PackagesProtectionRuleAccessLevel' + description 'Access level of a package protection rule resource' + + value 'DEVELOPER', value: Gitlab::Access::DEVELOPER, description: 'Developer access.' + value 'MAINTAINER', value: Gitlab::Access::MAINTAINER, description: 'Maintainer access.' + value 'OWNER', value: Gitlab::Access::OWNER, description: 'Owner access.' + end + end + end +end diff --git a/app/graphql/types/packages/protection/rule_package_type_enum.rb b/app/graphql/types/packages/protection/rule_package_type_enum.rb new file mode 100644 index 0000000000000000000000000000000000000000..28e9df76adc2c8058a69fba8255ca526c2ae465e --- /dev/null +++ b/app/graphql/types/packages/protection/rule_package_type_enum.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Packages + module Protection + class RulePackageTypeEnum < BaseEnum + graphql_name 'PackagesProtectionRulePackageType' + description 'Package type of a package protection rule resource' + + ::Packages::Protection::Rule.package_types.each_key do |package_type| + value package_type.upcase, value: package_type, + description: "Packages of the #{package_type} format" + end + end + end + end +end diff --git a/app/graphql/types/packages/protection/rule_type.rb b/app/graphql/types/packages/protection/rule_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e969d39ce2903d08c66427ac7e2a80d72556c82 --- /dev/null +++ b/app/graphql/types/packages/protection/rule_type.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module Packages + module Protection + class RuleType < ::Types::BaseObject + graphql_name 'PackagesProtectionRule' + description 'A packages protection rule designed to protect packages ' \ + 'from being pushed by users with a certain access level.' + + authorize :admin_package + + field :package_name_pattern, + GraphQL::Types::String, + null: false, + description: + 'Package name protected by the protection rule. For example `@my-scope/my-package-*`. ' \ + 'Wildcard character `*` allowed.' + + field :package_type, + Types::Packages::Protection::RulePackageTypeEnum, + null: false, + description: 'Package type protected by the protection rule. For example `NPM`.' + + field :push_protected_up_to_access_level, + Types::Packages::Protection::RuleAccessLevelEnum, + null: false, + description: + 'Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`.' + end + end + end +end diff --git a/app/services/packages/protection/create_rule_service.rb b/app/services/packages/protection/create_rule_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..f0bd7f3d90051de853e7e4520c683501b15e5920 --- /dev/null +++ b/app/services/packages/protection/create_rule_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Packages + module Protection + class CreateRuleService < BaseService + ALLOWED_ATTRIBUTES = %i[ + package_name_pattern + package_type + push_protected_up_to_access_level + ].freeze + + def execute + unless can?(current_user, :admin_package, project) + error_message = _('Unauthorized to create a package protection rule') + return service_response_error(message: error_message) + end + + package_protection_rule = project.package_protection_rules.create(params.slice(*ALLOWED_ATTRIBUTES)) + + unless package_protection_rule.persisted? + return service_response_error(message: package_protection_rule.errors.full_messages) + end + + ServiceResponse.success(payload: { package_protection_rule: package_protection_rule }) + rescue StandardError => e + service_response_error(message: e.message) + end + + private + + def service_response_error(message:) + ServiceResponse.error( + message: message, + payload: { package_protection_rule: nil } + ) + end + end + end +end diff --git a/config/feature_flags/development/packages_protected_packages.yml b/config/feature_flags/development/packages_protected_packages.yml new file mode 100644 index 0000000000000000000000000000000000000000..6e159248e0150204e77fa924c67d68fcddf723eb --- /dev/null +++ b/config/feature_flags/development/packages_protected_packages.yml @@ -0,0 +1,8 @@ +--- +name: packages_protected_packages +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/125915 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/416395 +milestone: '16.5' +type: development +group: group::package registry +default_enabled: false \ No newline at end of file diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index b4f57847408be275e09e5eb0f7bb8194968ad2c8..d35d5f3c6c4df9f3b7f61d93fc8d7f04f5a757d5 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2392,6 +2392,34 @@ Input type: `CreateNoteInput` | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `note` | [`Note`](#note) | Note after mutation. | +### `Mutation.createPackagesProtectionRule` + +Creates a protection rule to restrict access to project packages. Available only when feature flag `packages_protected_packages` is enabled. + +WARNING: +**Introduced** in 16.5. +This feature is an Experiment. It can be changed or removed at any time. + +Input type: `CreatePackagesProtectionRuleInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `packageNamePattern` | [`String!`](#string) | Package name protected by the protection rule. For example `@my-scope/my-package-*`. Wildcard character `*` allowed. | +| `packageType` | [`PackagesProtectionRulePackageType!`](#packagesprotectionrulepackagetype) | Package type protected by the protection rule. For example `NPM`. | +| `projectPath` | [`ID!`](#id) | Full path of the project where a protection rule is located. | +| `pushProtectedUpToAccessLevel` | [`PackagesProtectionRuleAccessLevel!`](#packagesprotectionruleaccesslevel) | Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`. | + +#### 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. | +| `packageProtectionRule` | [`PackagesProtectionRule`](#packagesprotectionrule) | Packages protection rule after mutation. | + ### `Mutation.createRequirement` Input type: `CreateRequirementInput` @@ -21533,6 +21561,18 @@ A packages cleanup policy designed to keep only packages and packages assets tha | `keepNDuplicatedPackageFiles` | [`PackagesCleanupKeepDuplicatedPackageFilesEnum!`](#packagescleanupkeepduplicatedpackagefilesenum) | Number of duplicated package files to retain. | | `nextRunAt` | [`Time`](#time) | Next time that this packages cleanup policy will be executed. | +### `PackagesProtectionRule` + +A packages protection rule designed to protect packages from being pushed by users with a certain access level. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `packageNamePattern` | [`String!`](#string) | Package name protected by the protection rule. For example `@my-scope/my-package-*`. Wildcard character `*` allowed. | +| `packageType` | [`PackagesProtectionRulePackageType!`](#packagesprotectionrulepackagetype) | Package type protected by the protection rule. For example `NPM`. | +| `pushProtectedUpToAccessLevel` | [`PackagesProtectionRuleAccessLevel!`](#packagesprotectionruleaccesslevel) | Max GitLab access level unable to push a package. For example `DEVELOPER`, `MAINTAINER`, `OWNER`. | + ### `PageInfo` Information about pagination in a connection. @@ -28532,6 +28572,24 @@ Values for sorting package. | `THIRTY_PACKAGE_FILES` | Value to keep 30 package files. | | `TWENTY_PACKAGE_FILES` | Value to keep 20 package files. | +### `PackagesProtectionRuleAccessLevel` + +Access level of a package protection rule resource. + +| Value | Description | +| ----- | ----------- | +| `DEVELOPER` | Developer access. | +| `MAINTAINER` | Maintainer access. | +| `OWNER` | Owner access. | + +### `PackagesProtectionRulePackageType` + +Package type of a package protection rule resource. + +| Value | Description | +| ----- | ----------- | +| `NPM` | Packages of the npm format. | + ### `PipelineConfigSourceEnum` | Value | Description | diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 283d28b87713324a9ea60249a467fcd864d3368e..96d652ddf4cad8f65aee2c9ed06c6b23fbda7f78 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -50404,6 +50404,9 @@ msgstr "" msgid "Unauthorized to access the cluster agent in this project" msgstr "" +msgid "Unauthorized to create a package protection rule" +msgstr "" + msgid "Unauthorized to create an environment" msgstr "" diff --git a/spec/graphql/types/packages/protection/rule_access_level_enum_spec.rb b/spec/graphql/types/packages/protection/rule_access_level_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..421b5fb0f399d714d64717d197fd74e99a9709d6 --- /dev/null +++ b/spec/graphql/types/packages/protection/rule_access_level_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackagesProtectionRuleAccessLevel'], feature_category: :package_registry do + it 'exposes all options' do + expect(described_class.values.keys).to match_array(%w[DEVELOPER MAINTAINER OWNER]) + end +end diff --git a/spec/graphql/types/packages/protection/rule_package_type_enum_spec.rb b/spec/graphql/types/packages/protection/rule_package_type_enum_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0d9772f285c57cc9a15b17afca67fabcaaffc6f --- /dev/null +++ b/spec/graphql/types/packages/protection/rule_package_type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackagesProtectionRulePackageType'], feature_category: :package_registry do + it 'exposes all options' do + expect(described_class.values.keys).to contain_exactly('NPM') + end +end diff --git a/spec/graphql/types/packages/protection/rule_type_spec.rb b/spec/graphql/types/packages/protection/rule_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a4a458d35682b978d04834d1bd9cb320182d4517 --- /dev/null +++ b/spec/graphql/types/packages/protection/rule_type_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['PackagesProtectionRule'], feature_category: :package_registry do + specify { expect(described_class.graphql_name).to eq('PackagesProtectionRule') } + + specify { expect(described_class.description).to be_present } + + specify { expect(described_class).to require_graphql_authorizations(:admin_package) } + + describe 'package_name_pattern' do + subject { described_class.fields['packageNamePattern'] } + + it { is_expected.to have_non_null_graphql_type(GraphQL::Types::String) } + end + + describe 'package_type' do + subject { described_class.fields['packageType'] } + + it { is_expected.to have_non_null_graphql_type(Types::Packages::Protection::RulePackageTypeEnum) } + end + + describe 'push_protected_up_to_access_level' do + subject { described_class.fields['pushProtectedUpToAccessLevel'] } + + it { is_expected.to have_non_null_graphql_type(Types::Packages::Protection::RuleAccessLevelEnum) } + end +end diff --git a/spec/requests/api/graphql/mutations/packages/protection/rule/create_spec.rb b/spec/requests/api/graphql/mutations/packages/protection/rule/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0c8526fa1cd488398f0a726b80e84c0611dc381 --- /dev/null +++ b/spec/requests/api/graphql/mutations/packages/protection/rule/create_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Creating the packages protection rule', :aggregate_failures, feature_category: :package_registry do + include GraphqlHelpers + using RSpec::Parameterized::TableSyntax + + let_it_be(:project) { create(:project) } + let_it_be(:user) { create(:user, maintainer_projects: [project]) } + + let(:package_protection_rule_attributes) { build_stubbed(:package_protection_rule, project: project) } + + let(:kwargs) do + { + project_path: project.full_path, + package_name_pattern: package_protection_rule_attributes.package_name_pattern, + package_type: "NPM", + push_protected_up_to_access_level: "MAINTAINER" + } + end + + let(:mutation) do + graphql_mutation(:create_packages_protection_rule, kwargs, + <<~QUERY + clientMutationId + errors + QUERY + ) + end + + let(:mutation_response) { graphql_mutation_response(:create_packages_protection_rule) } + + describe 'post graphql mutation' do + subject { post_graphql_mutation(mutation, current_user: user) } + + context 'without existing packages protection rule' do + it 'returns without error' do + subject + + expect_graphql_errors_to_be_empty + end + + it 'returns the created packages protection rule' do + expect { subject }.to change { ::Packages::Protection::Rule.count }.by(1) + + expect_graphql_errors_to_be_empty + expect(Packages::Protection::Rule.where(project: project).count).to eq 1 + + expect(Packages::Protection::Rule.where(project: project, + package_name_pattern: kwargs[:package_name_pattern])).to exist + end + + context 'when invalid fields are given' do + let(:kwargs) do + { + project_path: project.full_path, + package_name_pattern: '', + package_type: 'UNKNOWN_PACKAGE_TYPE', + push_protected_up_to_access_level: 'UNKNOWN_ACCESS_LEVEL' + } + end + + it 'returns error about required argument' do + subject + + expect_graphql_errors_to_include(/was provided invalid value for packageType/) + expect_graphql_errors_to_include(/pushProtectedUpToAccessLevel/) + end + end + end + + context 'when user does not have permission' do + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } } + let_it_be(:guest) { create(:user).tap { |u| project.add_guest(u) } } + let_it_be(:anonymous) { create(:user) } + + where(:user) do + [ref(:developer), ref(:reporter), ref(:guest), ref(:anonymous)] + end + + with_them do + it 'returns an error' do + expect { subject }.not_to change { ::Packages::Protection::Rule.count } + + expect_graphql_errors_to_include(/you don't have permission to perform this action/) + end + end + end + + context 'with existing packages protection rule' do + let_it_be(:existing_package_protection_rule) do + create(:package_protection_rule, project: project, push_protected_up_to_access_level: Gitlab::Access::DEVELOPER) + end + + context 'when package name pattern is slightly different' do + let(:kwargs) do + { + project_path: project.full_path, + # The field `package_name_pattern` is unique; this is why we change the value in a minimum way + package_name_pattern: "#{existing_package_protection_rule.package_name_pattern}-unique", + package_type: "NPM", + push_protected_up_to_access_level: "DEVELOPER" + } + end + + it 'returns the created packages protection rule' do + expect { subject }.to change { ::Packages::Protection::Rule.count }.by(1) + + expect(Packages::Protection::Rule.where(project: project).count).to eq 2 + expect(Packages::Protection::Rule.where(project: project, + package_name_pattern: kwargs[:package_name_pattern])).to exist + end + + it 'returns without error' do + subject + + expect_graphql_errors_to_be_empty + end + end + + context 'when field `package_name_pattern` is taken' do + let(:kwargs) do + { + project_path: project.full_path, + package_name_pattern: existing_package_protection_rule.package_name_pattern, + package_type: 'NPM', + push_protected_up_to_access_level: 'MAINTAINER' + } + end + + it 'returns without error' do + subject + + expect(mutation_response).to include 'errors' => ['Package name pattern has already been taken'] + end + + it 'does not create new package protection rules' do + expect { subject }.to change { Packages::Protection::Rule.count }.by(0) + + expect(Packages::Protection::Rule.where(project: project, + package_name_pattern: kwargs[:package_name_pattern], + push_protected_up_to_access_level: Gitlab::Access::MAINTAINER)).not_to exist + end + end + end + + context "when feature flag ':packages_protected_packages' disabled" do + before do + stub_feature_flags(packages_protected_packages: false) + end + + it 'does not create any package protection rules' do + expect { subject }.to change { Packages::Protection::Rule.count }.by(0) + + expect(Packages::Protection::Rule.where(project: project)).not_to exist + end + + it 'returns error of disabled feature flag' do + subject.tap { expect_graphql_errors_to_include(/'packages_protected_packages' feature flag is disabled/) } + end + end + end +end diff --git a/spec/services/packages/protection/create_rule_service_spec.rb b/spec/services/packages/protection/create_rule_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cd9cb74c29bb0a11e7e6655a0406fbb55a24a350 --- /dev/null +++ b/spec/services/packages/protection/create_rule_service_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Packages::Protection::CreateRuleService, '#execute', feature_category: :environment_management do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:maintainer) { create(:user).tap { |u| project.add_maintainer(u) } } + + let(:service) { described_class.new(project, current_user, params) } + let(:current_user) { maintainer } + let(:params) { attributes_for(:package_protection_rule) } + + subject { service.execute } + + shared_examples 'a successful service response' do + let(:package_protection_rule_count_expected) { 1 } + it { is_expected.to be_success } + + it do + is_expected.to have_attributes( + payload: include( + package_protection_rule: be_a(Packages::Protection::Rule) + ) + ) + end + + it { expect(subject.payload).to include(package_protection_rule: be_a(Packages::Protection::Rule)) } + + it do + expect { subject }.to change { Packages::Protection::Rule.count }.by(1) + + expect(Packages::Protection::Rule.where(project: project).count).to eq package_protection_rule_count_expected + expect(Packages::Protection::Rule.where(project: project, + package_name_pattern: params[:package_name_pattern])).to exist + end + end + + shared_examples 'an erroneous service response' do + let(:package_protection_rule_count_expected) { 0 } + it { is_expected.to be_error } + it { is_expected.to have_attributes(payload: include(package_protection_rule: nil)) } + + it do + expect { subject }.to change { Packages::Protection::Rule.count }.by(0) + + expect(Packages::Protection::Rule.where(project: project).count).to eq package_protection_rule_count_expected + expect(Packages::Protection::Rule.where(project: project, + package_name_pattern: params[:package_name_pattern])).not_to exist + end + end + + context 'without existing PackageProtectionRules' do + context 'when fields are valid' do + it_behaves_like 'a successful service response' + end + + context 'when fields are invalid' do + let(:params) do + { + package_name_pattern: '', + package_type: 'unknown_package_type', + push_protected_up_to_access_level: 1000 + } + end + + it_behaves_like 'an erroneous service response' + end + end + + context 'with existing PackageProtectionRule' do + let_it_be(:existing_package_protection_rule) { create(:package_protection_rule, project: project) } + + context 'when package name pattern is slightly different' do + let(:params) do + attributes_for( + :package_protection_rule, + # The field `package_name_pattern` is unique; this is why we change the value in a minimum way + package_name_pattern: "#{existing_package_protection_rule.package_name_pattern}-unique", + package_type: existing_package_protection_rule.package_type, + push_protected_up_to_access_level: existing_package_protection_rule.push_protected_up_to_access_level + ) + end + + it_behaves_like 'a successful service response' do + let(:package_protection_rule_count_expected) { 2 } + end + end + + context 'when field `package_name_pattern` is taken' do + let(:params) do + attributes_for( + :package_protection_rule, + package_name_pattern: existing_package_protection_rule.package_name_pattern, + package_type: existing_package_protection_rule.package_type, + push_protected_up_to_access_level: existing_package_protection_rule.push_protected_up_to_access_level + ) + end + + it { is_expected.to be_error } + + it do + expect { subject }.to change { Packages::Protection::Rule.count }.by(0) + + expect(Packages::Protection::Rule.where(project: project).count).to eq 1 + expect( + Packages::Protection::Rule.where( + project: project, + package_name_pattern: params[:package_name_pattern] + ) + ).to exist + end + end + end + + context 'when disallowed params are passed' do + let(:params) do + attributes_for(:package_protection_rule) + .merge( + project_id: 1, + unsupported_param: 'unsupported_param_value' + ) + end + + it_behaves_like 'a successful service response' + end + + context 'with forbidden user access level (project developer role)' do + # Because of the access level hierarchy, we can assume that + # other access levels below developer role will also not be able to + # create package protection rules. + let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } } + + let(:current_user) { developer } + + it_behaves_like 'an erroneous service response' + + it { is_expected.to have_attributes(message: match(/Unauthorized/)) } + end +end