Commit a4dfe1f2 authored by Imre Farkas's avatar Imre Farkas

Merge branch '216987-prevent-forking-outside-a-group' into 'master'

Resolve "Prevent forking outside a group"

Closes #216987

See merge request gitlab-org/gitlab!36848
parents 5a231893 c02fa277
......@@ -37,6 +37,7 @@
= render 'groups/settings/default_branch_protection', f: f, group: @group
= render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/subgroup_creation_level', f: f, group: @group
= render_if_exists 'groups/settings/prevent_forking', f: f, group: @group
= render 'groups/settings/two_factor_auth', f: f
= render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group
= render_if_exists 'groups/member_lock_setting', f: f, group: @group
......
---
title: Add Prevent forking outside group feature
merge_request: 36848
author:
type: added
# frozen_string_literal: true
class AddPreventForkingToNamespaceSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :namespace_settings, :prevent_forking_outside_group, :boolean, null: false, default: false
end
end
dbb84d05cfe6d2ef143b9321b2b089c66d705f01ced64756032622f64f8e3eed
\ No newline at end of file
......@@ -13257,7 +13257,8 @@ CREATE TABLE public.namespace_root_storage_statistics (
CREATE TABLE public.namespace_settings (
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
namespace_id integer NOT NULL
namespace_id integer NOT NULL,
prevent_forking_outside_group boolean DEFAULT false NOT NULL
);
CREATE TABLE public.namespace_statistics (
......
......@@ -750,6 +750,7 @@ PUT /groups/:id
| `file_template_project_id` | integer | no | **(PREMIUM)** The ID of a project to load custom file templates from. |
| `shared_runners_minutes_limit` | integer | no | **(STARTER ONLY)** Pipeline minutes quota for this group (included in plan). Can be `nil` (default; inherit system default), `0` (unlimited) or `> 0` |
| `extra_shared_runners_minutes_limit` | integer | no | **(STARTER ONLY)** Extra pipeline minutes quota for this group (purchased in addition to the minutes included in the plan). |
| `prevent_forking_outside_group` | boolean | no | **(PREMIUM)** When enabled, users can **not** fork projects from this group to external namespaces
NOTE: **Note:**
The `projects` and `shared_projects` attributes in the response are deprecated and will be [removed in API v5](https://gitlab.com/gitlab-org/gitlab/-/issues/213797).
......
......@@ -668,6 +668,23 @@ To enable delayed deletion of projects:
1. Expand the **Permissions, LFS, 2FA** section, and check **Enable delayed project removal**.
1. Click **Save changes**.
#### Prevent project forking outside group **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216987) in GitLab 13.3.
By default, projects within a group can be forked.
Optionally, on [Premium or Silver](https://about.gitlab.com/pricing/) or higher tiers,
you can prevent the projects within a group from being forked outside of the current top-level group.
Previously this setting was available only for groups enforcing group managed account. This setting will be
removed from SAML setting page and migrated to group setting, but in the interim period of changes both of those settings will be taken into consideration, if even one is set to `true` then it will be assumed group does not allow forking projects outside.
To enable prevent project forking:
1. Navigate to the top-level group's **Settings > General** page.
1. Expand the **Permissions, LFS, 2FA** section, and check **Prevent project forking outside current group**.
1. Click **Save changes**.
### Advanced settings
- **Projects**: View all projects within that group, add members to each project,
......
......@@ -4,6 +4,7 @@ module EE
module GroupsController
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
include PreventForkingHelper
prepended do
alias_method :ee_authorize_admin_group!, :authorize_admin_group!
......@@ -71,6 +72,7 @@ module EE
params_ee << :max_pages_size if can?(current_user, :update_max_pages_size)
params_ee << :max_personal_access_token_lifetime if current_group&.personal_access_token_expiration_policy_available?
params_ee << :delayed_project_removal if current_group&.feature_available?(:adjourned_deletion_for_projects_and_groups)
params_ee << :prevent_forking_outside_group if can_change_prevent_forking?(current_user, current_group)
end
end
......
......@@ -11,14 +11,9 @@ module EE
targets = super
root_group = project.group&.root_ancestor
return targets unless root_group&.prevent_forking_outside_group?
return targets unless root_group&.saml_provider
if root_group.saml_provider.prohibited_outer_forks?
targets = targets.where(id: root_group.self_and_descendants)
end
targets
targets.where(id: root_group.self_and_descendants)
end
# rubocop: enable CodeReuse/ActiveRecord
end
......
# frozen_string_literal: true
module PreventForkingHelper
def can_change_prevent_forking?(current_user, group)
can?(current_user, :change_prevent_group_forking, group)
end
end
......@@ -386,6 +386,15 @@ module EE
owners.pluck(:email)
end
# this method will be delegated to namespace_settings, but as we need to wait till
# all groups will have namespace_settings created via background migration,
# we need to serve it from this class
def prevent_forking_outside_group?
return namespace_settings.prevent_forking_outside_group? if namespace_settings
root_ancestor.saml_provider&.prohibited_outer_forks?
end
private
def custom_project_templates_group_allowed
......
......@@ -3,5 +3,15 @@
module EE
module NamespaceSetting
extend ActiveSupport::Concern
delegate :root_ancestor, to: :namespace
def prevent_forking_outside_group?
saml_setting = root_ancestor.saml_provider&.prohibited_outer_forks?
return saml_setting unless namespace.feature_available?(:group_forking_protection)
saml_setting || root_ancestor.namespace_settings&.prevent_forking_outside_group
end
end
end
......@@ -79,6 +79,7 @@ class License < ApplicationRecord
github_project_service_integration
group_allowed_email_domains
group_coverage_reports
group_forking_protection
group_ip_restriction
group_merge_request_analytics
group_project_templates
......
......@@ -45,6 +45,10 @@ module EE
@subject.feature_available?(:security_dashboard)
end
condition(:prevent_group_forking_available) do
@subject.feature_available?(:group_forking_protection)
end
condition(:needs_new_sso_session) do
sso_enforcement_prevents_access?
end
......@@ -135,6 +139,10 @@ module EE
enable :read_group_cycle_analytics, :create_group_stage, :read_group_stage, :update_group_stage, :delete_group_stage
end
rule { owner & ~has_parent & prevent_group_forking_available }.policy do
enable :change_prevent_group_forking
end
rule { can?(:read_group) & dependency_proxy_available }
.enable :read_dependency_proxy
......
......@@ -93,6 +93,7 @@ module EE
def handle_changes
handle_allowed_email_domains_update
handle_ip_restriction_update
handle_settings_update
end
def handle_ip_restriction_update
......@@ -111,6 +112,13 @@ module EE
AllowedEmailDomains::UpdateService.new(current_user, group, comma_separated_domains).execute
end
def handle_settings_update
settings_params = params.slice(:prevent_forking_outside_group)
params.delete(:prevent_forking_outside_group)
NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute
end
def log_audit_event
EE::Audit::GroupChangesAuditor.new(current_user, group).execute
end
......
# frozen_string_literal: true
module EE
# This class is responsible for updating the namespace settings of a specific group.
module NamespaceSettings
class UpdateService
include ::Gitlab::Allowable
def initialize(current_user, group, settings)
@current_user = current_user
@group = group
@settings_params = settings
end
def execute
unless valid?
group.errors.add(:prevent_forking_outside_group, s_('GroupSettings|Prevent forking setting was not saved'))
return
end
if group.namespace_settings
group.namespace_settings.attributes = settings_params
else
group.build_namespace_settings(settings_params)
end
end
private
attr_reader :current_user, :group, :settings_params
def valid?
if settings_params.key?(:prevent_forking_outside_group)
can_update_prevent_forking?
else
true
end
end
def can_update_prevent_forking?
can?(current_user, :change_prevent_group_forking, group)
end
end
end
end
%h5= _('Prevent project forking outside current group')
.form-group.gl-mb-3
.form-check
= f.check_box :prevent_forking_outside_group, checked: group.prevent_forking_outside_group?, class: 'form-check-input', disabled: !can_change_prevent_forking?(current_user, group)
= f.label :prevent_forking_outside_group, class: 'form-check-label' do
%span.gl-display-block= s_('GroupSettings|Prevent forking outside of the group')
%span.text-muted= s_('GroupSettings|This setting will prevent group members from forking projects outside of the group.')
......@@ -9,6 +9,8 @@ module EE
prepended do
expose :shared_runners_minutes_limit
expose :extra_shared_runners_minutes_limit
expose :prevent_forking_outside_group?,
as: :prevent_forking_outside_group
end
end
end
......
......@@ -43,6 +43,9 @@ module EE
params.delete(:file_template_project_id) unless
group.feature_available?(:custom_file_templates_for_namespace)
params.delete(:prevent_forking_outside_group) unless
can?(current_user, :change_prevent_group_forking, group)
super
end
......
......@@ -18,6 +18,7 @@ module EE
params :optional_update_params_ee do
optional :file_template_project_id, type: Integer, desc: 'The ID of a project to use for custom templates in this group'
optional :prevent_forking_outside_group, type: ::Grape::API::Boolean, desc: 'Prevent forking projects inside this group to external namespaces'
end
params :optional_projects_params_ee do
......
......@@ -537,5 +537,44 @@ RSpec.describe GroupsController do
end
end
end
context 'when `prevent_forking_outside_group` is specified' do
using RSpec::Parameterized::TableSyntax
subject { put :update, params: params }
shared_examples_for 'updates the attribute if needed' do
it 'updates the attribute' do
subject
expect(response).to have_gitlab_http_status(:found)
expect(group.reload.prevent_forking_outside_group?).to eq(result)
end
end
context 'authenticated as group owner' do
where(:feature_enabled, :prevent_forking_outside_group, :result) do
false | false | nil
false | true | nil
true | false | false
true | true | true
end
with_them do
let(:params) do
{ id: group.to_param, group: { prevent_forking_outside_group: prevent_forking_outside_group } }
end
before do
group.add_owner(user)
sign_in(user)
stub_licensed_features(group_forking_protection: feature_enabled)
end
it_behaves_like 'updates the attribute if needed'
end
end
end
end
end
......@@ -19,28 +19,63 @@ RSpec.describe ForkTargetsFinder do
project_group.add_reporter(user)
outer_group.add_owner(user)
inner_subgroup.add_owner(user)
stub_licensed_features(group_saml: true)
stub_licensed_features(group_saml: true, group_forking_protection: true)
end
context 'when project root group prohibits outer forks' do
let(:project_group) do
create(:saml_provider, :enforced_group_managed_accounts, prohibited_outer_forks: true).group
end
context 'when it is configured on saml level' do
before do
create(:namespace_settings, namespace: project_group, prevent_forking_outside_group: false)
end
let(:project_group) do
create(:saml_provider, :enforced_group_managed_accounts, prohibited_outer_forks: true).group
end
it 'returns namespaces with the same root group as project one only' do
expect(fork_targets).to be_a(ActiveRecord::Relation)
expect(fork_targets).to match_array([inner_subgroup])
end
end
it 'returns namespaces with the same root group as project one only' do
expect(fork_targets).to be_a(ActiveRecord::Relation)
expect(fork_targets).to match_array([inner_subgroup])
end
context 'when project root does not prohibit outer forks' do
let(:project_group) do
create(:saml_provider, :enforced_group_managed_accounts, prohibited_outer_forks: false).group
context 'when project root does not prohibit outer forks' do
let(:project_group) do
create(:saml_provider, :enforced_group_managed_accounts, prohibited_outer_forks: false).group
end
it 'returns outer namespaces as well as inner' do
expect(fork_targets).to be_a(ActiveRecord::Relation)
expect(fork_targets).to match_array([outer_group, inner_subgroup, user.namespace])
end
end
end
it 'returns outer namespaces as well as inner' do
expect(fork_targets).to be_a(ActiveRecord::Relation)
expect(fork_targets).to match_array([outer_group, inner_subgroup, user.namespace])
context 'when it is configured on group level' do
let(:project_group) do
create(:group)
end
let(:user) { create :user }
context 'when project root prohibits outer forks' do
before do
create(:namespace_settings, namespace: project_group, prevent_forking_outside_group: true)
end
it 'returns namespaces with the same root group as project one only' do
expect(fork_targets).to be_a(ActiveRecord::Relation)
expect(fork_targets).to match_array([inner_subgroup])
end
end
context 'when project root does not prohibit outer forks' do
before do
create(:namespace_settings, namespace: project_group, prevent_forking_outside_group: false)
end
it 'returns outer namespaces as well as inner' do
expect(fork_targets).to be_a(ActiveRecord::Relation)
expect(fork_targets).to match_array([outer_group, inner_subgroup, user.namespace])
end
end
end
end
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe PreventForkingHelper do
let(:group) { create :group }
let(:owner) { group.owner }
it 'calls proper ability method' do
expect(helper).to receive(:can?).with(owner, :change_prevent_group_forking, group)
helper.can_change_prevent_forking?(owner, group)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe NamespaceSetting do
let(:group) { create(:group) }
describe '#prevent_forking_outside_group?' do
context 'with feature available' do
before do
stub_licensed_features(group_forking_protection: true)
end
context 'group with no associated saml provider' do
let(:setting) { create(:namespace_settings, namespace: group, prevent_forking_outside_group: true) }
it 'returns namespace setting' do
expect(setting.prevent_forking_outside_group?).to eq(true)
end
end
context 'group with associated saml provider' do
before do
stub_licensed_features(group_saml: true, group_forking_protection: true)
end
context 'when it is configured to true on saml level' do
let(:setting) { create(:namespace_settings, namespace: group, prevent_forking_outside_group: true) }
before do
create(:saml_provider, :enforced_group_managed_accounts, prohibited_outer_forks: true, group: group)
end
it 'returns true' do
expect(setting.prevent_forking_outside_group?).to eq(true)
end
end
context 'when it is configured to false on saml level' do
let(:setting) { create(:namespace_settings, namespace: group, prevent_forking_outside_group: false) }
before do
create(:saml_provider, :enforced_group_managed_accounts, prohibited_outer_forks: false, group: group)
end
it 'returns false' do
expect(setting.prevent_forking_outside_group?).to eq(false)
end
context 'when setting is configured on namespace level' do
let(:setting) { create(:namespace_settings, namespace: group, prevent_forking_outside_group: true) }
it 'returns namespace setting' do
expect(setting.prevent_forking_outside_group?).to eq(true)
end
end
end
end
end
context 'without feature available' do
let(:setting) { create(:namespace_settings, namespace: group, prevent_forking_outside_group: true) }
it 'returns false' do
expect(setting.prevent_forking_outside_group?).to be_falsey
end
context 'when saml setting is available' do
before do
stub_licensed_features(group_saml: true)
end
context 'when it is configured to true on saml level' do
let(:setting) { create(:namespace_settings, namespace: group, prevent_forking_outside_group: false) }
before do
create(:saml_provider, :enforced_group_managed_accounts, prohibited_outer_forks: true, group: group)
end
it 'returns true' do
expect(setting.prevent_forking_outside_group?).to eq(true)
end
end
context 'when it is configured to false on saml level' do
let(:setting) { create(:namespace_settings, namespace: group, prevent_forking_outside_group: false) }
before do
create(:saml_provider, :enforced_group_managed_accounts, prohibited_outer_forks: false, group: group)
end
it 'returns false' do
expect(setting.prevent_forking_outside_group?).to eq(false)
end
end
end
end
end
end
......@@ -630,6 +630,46 @@ RSpec.describe GroupPolicy do
end
end
describe 'change_prevent_group_forking' do
context 'when feature is disabled' do
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_disallowed(:change_prevent_group_forking) }
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_disallowed(:change_prevent_group_forking) }
end
end
context 'when feature is enabled' do
before do
stub_licensed_features(group_forking_protection: true)
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:change_prevent_group_forking) }
context 'when group has parent' do
let(:group) { create(:group, :private, parent: create(:group)) }
it { is_expected.to be_disallowed(:change_prevent_group_forking) }
end
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_disallowed(:change_prevent_group_forking) }
end
end
end
describe 'read_group_security_dashboard & create_vulnerability_export' do
let(:abilities) { %i(read_group_security_dashboard create_vulnerability_export) }
......
......@@ -247,6 +247,36 @@ RSpec.describe API::Groups do
end
end
end
context 'prevent_forking_outside_group' do
using RSpec::Parameterized::TableSyntax
context 'authenticated as group owner' do
where(:feature_enabled, :prevent_forking_outside_group, :result) do
false | false | nil
false | true | nil
true | false | false
true | true | true
end
with_them do
let(:params) { { prevent_forking_outside_group: prevent_forking_outside_group } }
before do
group.add_owner(user)
stub_licensed_features(group_forking_protection: feature_enabled)
end
it 'updates the attribute as expected' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['prevent_forking_outside_group']).to eq(result)
end
end
end
end
end
describe "POST /groups" do
......
......@@ -948,7 +948,7 @@ RSpec.describe API::Projects do
end
before do
stub_licensed_features(group_saml: true)
stub_licensed_features(group_saml: true, group_forking_protection: true)
end
context 'and target namespace is outer' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe EE::NamespaceSettings::UpdateService do
let(:group) { create(:group) }
let(:user) { create(:user) }
subject { described_class.new(user, group, params).execute }
describe '#execute' do
before do
create(:namespace_settings, namespace: group, prevent_forking_outside_group: false)
end
context 'as a normal user' do
let(:params) { { prevent_forking_outside_group: true } }
it 'does not change settings' do
subject
expect { group.save! }
.not_to(change { group.namespace_settings.prevent_forking_outside_group })
end
it 'registers an error' do
subject
expect(group.errors[:prevent_forking_outside_group]).to include('Prevent forking setting was not saved')
end
end
context 'as a group owner' do
before do
group.add_owner(user)
end
context 'for a group that does not have prevent forking feature' do
let(:params) { { prevent_forking_outside_group: true } }
it 'does not change settings' do
subject
expect { group.save! }
.not_to(change { group.namespace_settings.prevent_forking_outside_group })
end
it 'registers an error' do
subject
expect(group.errors[:prevent_forking_outside_group]).to include('Prevent forking setting was not saved')
end
end
context 'for a group that has prevent forking feature' do
let(:params) { { prevent_forking_outside_group: true } }
before do
stub_licensed_features(group_forking_protection: true)
end
it 'changes settings' do
subject
group.save!
expect(group.namespace_settings.reload.prevent_forking_outside_group).to eq(true)
end
end
end
end
end
......@@ -12019,6 +12019,12 @@ msgstr ""
msgid "GroupSettings|Please choose a group URL with no special characters."
msgstr ""
msgid "GroupSettings|Prevent forking outside of the group"
msgstr ""
msgid "GroupSettings|Prevent forking setting was not saved"
msgstr ""
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr ""
......@@ -12055,6 +12061,9 @@ msgstr ""
msgid "GroupSettings|This setting will prevent group members from being notified if the group is mentioned."
msgstr ""
msgid "GroupSettings|This setting will prevent group members from forking projects outside of the group."
msgstr ""
msgid "GroupSettings|Transfer group"
msgstr ""
......@@ -17935,6 +17944,9 @@ msgstr ""
msgid "Prevent environment from auto-stopping"
msgstr ""
msgid "Prevent project forking outside current group"
msgstr ""
msgid "Prevent users from changing their profile name"
msgstr ""
......
# frozen_string_literal: true
FactoryBot.define do
factory :namespace_settings, class: 'NamespaceSetting' do
namespace
end
end
......@@ -30,6 +30,10 @@ FactoryBot.define do
association :root_storage_statistics, factory: :namespace_root_storage_statistics
end
trait :with_namespace_settings do
association :namespace_settings, factory: :namespace_settings
end
# Construct a hierarchy underneath the namespace.
# Each namespace will have `children` amount of children,
# and `depth` levels of descendants.
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment