diff --git a/app/models/concerns/featurable.rb b/app/models/concerns/featurable.rb new file mode 100644 index 0000000000000000000000000000000000000000..60aa46ce04ca9472cead2c45c444f090e82e4510 --- /dev/null +++ b/app/models/concerns/featurable.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# == Featurable concern +# +# This concern adds features (tools) functionality to Project and Group +# To enable features you need to call `set_available_features` +# +# Example: +# +# class ProjectFeature +# include Featurable +# set_available_features %i(wiki merge_request) + +module Featurable + extend ActiveSupport::Concern + + # Can be enabled only for members, everyone or disabled + # Access control is made only for non private containers. + # + # Permission levels: + # + # Disabled: not enabled for anyone + # Private: enabled only for team members + # Enabled: enabled for everyone able to access the project + # Public: enabled for everyone (only allowed for pages) + DISABLED = 0 + PRIVATE = 10 + ENABLED = 20 + PUBLIC = 30 + + STRING_OPTIONS = HashWithIndifferentAccess.new({ + 'disabled' => DISABLED, + 'private' => PRIVATE, + 'enabled' => ENABLED, + 'public' => PUBLIC + }).freeze + + class_methods do + def set_available_features(available_features = []) + @available_features = available_features + + class_eval do + available_features.each do |feature| + define_method("#{feature}_enabled?") do + public_send("#{feature}_access_level") > DISABLED # rubocop:disable GitlabSecurity/PublicSend + end + end + end + end + + def available_features + @available_features + end + + def access_level_attribute(feature) + feature = ensure_feature!(feature) + + "#{feature}_access_level".to_sym + end + + def quoted_access_level_column(feature) + attribute = connection.quote_column_name(access_level_attribute(feature)) + table = connection.quote_table_name(table_name) + + "#{table}.#{attribute}" + end + + def access_level_from_str(level) + STRING_OPTIONS.fetch(level) + end + + def str_from_access_level(level) + STRING_OPTIONS.key(level) + end + + def ensure_feature!(feature) + feature = feature.model_name.plural if feature.respond_to?(:model_name) + feature = feature.to_sym + raise ArgumentError, "invalid feature: #{feature}" unless available_features.include?(feature) + + feature + end + end + + def access_level(feature) + public_send(self.class.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend + end + + def feature_available?(feature, user) + # This feature might not be behind a feature flag at all, so default to true + return false unless ::Feature.enabled?(feature, user, default_enabled: true) + + get_permission(user, feature) + end + + def string_access_level(feature) + self.class.str_from_access_level(access_level(feature)) + end +end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 9201cd24d6617e0b51e35d909ff801b3e863d9bc..b3ebcbd4b177ef7b1c0d2de029b73f4e71de041a 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -1,51 +1,16 @@ # frozen_string_literal: true class ProjectFeature < ApplicationRecord - # == Project features permissions - # - # Grants access level to project tools - # - # Tools can be enabled only for users, everyone or disabled - # Access control is made only for non private projects - # - # levels: - # - # Disabled: not enabled for anyone - # Private: enabled only for team members - # Enabled: enabled for everyone able to access the project - # Public: enabled for everyone (only allowed for pages) - # - - # Permission levels - DISABLED = 0 - PRIVATE = 10 - ENABLED = 20 - PUBLIC = 30 + include Featurable FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze + + set_available_features(FEATURES) + PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER, metrics_dashboard: Gitlab::Access::REPORTER }.freeze PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze - STRING_OPTIONS = HashWithIndifferentAccess.new({ - 'disabled' => DISABLED, - 'private' => PRIVATE, - 'enabled' => ENABLED, - 'public' => PUBLIC - }).freeze class << self - def access_level_attribute(feature) - feature = ensure_feature!(feature) - - "#{feature}_access_level".to_sym - end - - def quoted_access_level_column(feature) - attribute = connection.quote_column_name(access_level_attribute(feature)) - table = connection.quote_table_name(table_name) - - "#{table}.#{attribute}" - end - def required_minimum_access_level(feature) feature = ensure_feature!(feature) @@ -60,24 +25,6 @@ def required_minimum_access_level_for_private_project(feature) required_minimum_access_level(feature) end end - - def access_level_from_str(level) - STRING_OPTIONS.fetch(level) - end - - def str_from_access_level(level) - STRING_OPTIONS.key(level) - end - - private - - def ensure_feature!(feature) - feature = feature.model_name.plural if feature.respond_to?(:model_name) - feature = feature.to_sym - raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature) - - feature - end end # Default scopes force us to unscope here since a service may need to check @@ -107,45 +54,6 @@ def ensure_feature!(feature) end end - def feature_available?(feature, user) - # This feature might not be behind a feature flag at all, so default to true - return false unless ::Feature.enabled?(feature, user, default_enabled: true) - - get_permission(user, feature) - end - - def access_level(feature) - public_send(ProjectFeature.access_level_attribute(feature)) # rubocop:disable GitlabSecurity/PublicSend - end - - def string_access_level(feature) - ProjectFeature.str_from_access_level(access_level(feature)) - end - - def builds_enabled? - builds_access_level > DISABLED - end - - def wiki_enabled? - wiki_access_level > DISABLED - end - - def merge_requests_enabled? - merge_requests_access_level > DISABLED - end - - def forking_enabled? - forking_access_level > DISABLED - end - - def issues_enabled? - issues_access_level > DISABLED - end - - def pages_enabled? - pages_access_level > DISABLED - end - def public_pages? return true unless Gitlab.config.pages.access_control @@ -164,7 +72,7 @@ def private_pages? # which cannot be higher than repository access level def repository_children_level validator = lambda do |field| - level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend + level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend not_allowed = level > repository_access_level self.errors.add(field, "cannot have higher visibility level than repository access level") if not_allowed end @@ -175,8 +83,8 @@ def repository_children_level # Validates access level for other than pages cannot be PUBLIC def allowed_access_levels validator = lambda do |field| - level = public_send(field) || ProjectFeature::ENABLED # rubocop:disable GitlabSecurity/PublicSend - not_allowed = level > ProjectFeature::ENABLED + level = public_send(field) || ENABLED # rubocop:disable GitlabSecurity/PublicSend + not_allowed = level > ENABLED self.errors.add(field, "cannot have public visibility level") if not_allowed end diff --git a/changelogs/unreleased/208412-featurable.yml b/changelogs/unreleased/208412-featurable.yml new file mode 100644 index 0000000000000000000000000000000000000000..02adcccec0b2492fe043036e17f86801b0172475 --- /dev/null +++ b/changelogs/unreleased/208412-featurable.yml @@ -0,0 +1,5 @@ +--- +title: Extract featurable concern from ProjectFeature +merge_request: 31700 +author: Alexander Randa +type: other diff --git a/ee/spec/elastic_integration/global_search_spec.rb b/ee/spec/elastic_integration/global_search_spec.rb index 746d0d7f811abae25b3b831d804f34120653fd58..1d57f07d259af1b679668f127185c16afd270d07 100644 --- a/ee/spec/elastic_integration/global_search_spec.rb +++ b/ee/spec/elastic_integration/global_search_spec.rb @@ -160,7 +160,7 @@ def create_items(project, feature_settings = nil) # access_level can be :disabled, :enabled or :private def feature_settings(access_level) - Hash[features.collect { |k| ["#{k}_access_level", ProjectFeature.const_get(access_level.to_s.upcase, false)] }] + Hash[features.collect { |k| ["#{k}_access_level", Featurable.const_get(access_level.to_s.upcase, false)] }] end def expect_no_items_to_be_found(user) diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..89720e3652c6a82706cb66ba1b8d5e917f8c7c5f --- /dev/null +++ b/spec/models/concerns/featurable_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Featurable do + let_it_be(:user) { create(:user) } + let(:project) { create(:project) } + let(:feature_class) { subject.class } + let(:features) { feature_class::FEATURES } + + subject { project.project_feature } + + describe '.quoted_access_level_column' do + it 'returns the table name and quoted column name for a feature' do + expected = '"project_features"."issues_access_level"' + + expect(feature_class.quoted_access_level_column(:issues)).to eq(expected) + end + end + + describe '.access_level_attribute' do + it { expect(feature_class.access_level_attribute(:wiki)).to eq :wiki_access_level } + + it 'raises error for unspecified feature' do + expect { feature_class.access_level_attribute(:unknown) } + .to raise_error(ArgumentError, /invalid feature: unknown/) + end + end + + describe '.set_available_features' do + let!(:klass) do + Class.new do + include Featurable + set_available_features %i(feature1 feature2) + + def feature1_access_level + Featurable::DISABLED + end + + def feature2_access_level + Featurable::ENABLED + end + end + end + let!(:instance) { klass.new } + + it { expect(klass.available_features).to eq [:feature1, :feature2] } + it { expect(instance.feature1_enabled?).to be_falsey } + it { expect(instance.feature2_enabled?).to be_truthy } + end + + describe '.available_features' do + it { expect(feature_class.available_features).to include(*features) } + end + + describe '#access_level' do + it 'returns access level' do + expect(subject.access_level(:wiki)).to eq(subject.wiki_access_level) + end + end + + describe '#feature_available?' do + let(:features) { %w(issues wiki builds merge_requests snippets repository pages metrics_dashboard) } + + context 'when features are disabled' do + it "returns false" do + update_all_project_features(project, features, ProjectFeature::DISABLED) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + end + + context 'when features are enabled only for team members' do + it "returns false when user is not a team member" do + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + + it "returns true when user is a team member" do + project.add_developer(user) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + end + end + + it "returns true when user is a member of project group" do + group = create(:group) + project = create(:project, namespace: group) + group.add_developer(user) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + end + end + + context 'when admin mode is enabled', :enable_admin_mode do + it "returns true if user is an admin" do + user.update_attribute(:admin, true) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" + end + end + end + + context 'when admin mode is disabled' do + it "returns false when user is an admin" do + user.update_attribute(:admin, true) + + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" + end + end + end + end + + context 'when feature is enabled for everyone' do + it "returns true" do + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + + context 'when feature is disabled by a feature flag' do + it 'returns false' do + stub_feature_flags(issues: false) + + expect(project.feature_available?(:issues, user)).to eq(false) + end + end + + context 'when feature is enabled by a feature flag' do + it 'returns true' do + stub_feature_flags(issues: true) + + expect(project.feature_available?(:issues, user)).to eq(true) + end + end + end + + describe '#*_enabled?' do + let(:features) { %w(wiki builds merge_requests) } + + it "returns false when feature is disabled" do + update_all_project_features(project, features, ProjectFeature::DISABLED) + + features.each do |feature| + expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed" + end + end + + it "returns true when feature is enabled only for team members" do + update_all_project_features(project, features, ProjectFeature::PRIVATE) + + features.each do |feature| + expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" + end + end + + it "returns true when feature is enabled for everyone" do + features.each do |feature| + expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" + end + end + end + + def update_all_project_features(project, features, value) + project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h + project.project_feature.update(project_feature_attributes) + end +end diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb index e072cc21b38300a4b355fb225f6d7d9be9f30d99..e33ea75bc5d27bd2b15d5920344ee5a4aff6b2a4 100644 --- a/spec/models/project_feature_spec.rb +++ b/spec/models/project_feature_spec.rb @@ -18,106 +18,6 @@ end end - describe '.quoted_access_level_column' do - it 'returns the table name and quoted column name for a feature' do - expected = '"project_features"."issues_access_level"' - - expect(described_class.quoted_access_level_column(:issues)).to eq(expected) - end - end - - describe '#feature_available?' do - let(:features) { %w(issues wiki builds merge_requests snippets repository pages metrics_dashboard) } - - context 'when features are disabled' do - it "returns false" do - update_all_project_features(project, features, ProjectFeature::DISABLED) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end - end - end - - context 'when features are enabled only for team members' do - it "returns false when user is not a team member" do - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end - end - - it "returns true when user is a team member" do - project.add_developer(user) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" - end - end - - it "returns true when user is a member of project group" do - group = create(:group) - project = create(:project, namespace: group) - group.add_developer(user) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" - end - end - - context 'when admin mode is enabled', :enable_admin_mode do - it "returns true if user is an admin" do - user.update_attribute(:admin, true) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed" - end - end - end - - context 'when admin mode is disabled' do - it "returns false when user is an admin" do - user.update_attribute(:admin, true) - - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed" - end - end - end - end - - context 'when feature is enabled for everyone' do - it "returns true" do - expect(project.feature_available?(:issues, user)).to eq(true) - end - end - - context 'when feature is disabled by a feature flag' do - it 'returns false' do - stub_feature_flags(issues: false) - - expect(project.feature_available?(:issues, user)).to eq(false) - end - end - - context 'when feature is enabled by a feature flag' do - it 'returns true' do - stub_feature_flags(issues: true) - - expect(project.feature_available?(:issues, user)).to eq(true) - end - end - end - context 'repository related features' do before do project.project_feature.update( @@ -153,32 +53,6 @@ end end - describe '#*_enabled?' do - let(:features) { %w(wiki builds merge_requests) } - - it "returns false when feature is disabled" do - update_all_project_features(project, features, ProjectFeature::DISABLED) - - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed" - end - end - - it "returns true when feature is enabled only for team members" do - update_all_project_features(project, features, ProjectFeature::PRIVATE) - - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" - end - end - - it "returns true when feature is enabled for everyone" do - features.each do |feature| - expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed" - end - end - end - describe 'default pages access level' do subject { project_feature.pages_access_level } @@ -313,9 +187,4 @@ expect(described_class.required_minimum_access_level_for_private_project(:issues)).to eq(Gitlab::Access::GUEST) end end - - def update_all_project_features(project, features, value) - project_feature_attributes = features.map { |f| ["#{f}_access_level", value] }.to_h - project.project_feature.update(project_feature_attributes) - end end