summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-04-13 08:46:41 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-04-13 08:46:41 +0000
commit79eaa2f05006e2f4b46ebdc2a73ab38e5f2d45d7 (patch)
treeba36ed8f6b55945821060aafb3d326f6ce3dd2de
parent9062342781760b36f12c3321e1ef8f007b9f6c86 (diff)
downloadgitlab-ce-79eaa2f05006e2f4b46ebdc2a73ab38e5f2d45d7.tar.gz
Add latest changes from gitlab-org/security/gitlab@12-9-stable-ee
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock16
-rw-r--r--app/models/active_session.rb99
-rw-r--r--app/services/groups/destroy_service.rb8
-rw-r--r--changelogs/unreleased/213665-update_project_authorizations_on_group_delete.yml5
-rw-r--r--changelogs/unreleased/security-update-rack-2-0-9.yml5
-rw-r--r--db/post_migrate/20200204113225_schedule_recalculate_project_authorizations_third_run.rb28
-rw-r--r--spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb28
-rw-r--r--spec/models/active_session_spec.rb48
-rw-r--r--spec/services/groups/destroy_service_spec.rb51
-rw-r--r--spec/support/rails/test_case_patch.rb53
11 files changed, 283 insertions, 60 deletions
diff --git a/Gemfile b/Gemfile
index 51350401807..326174c75f2 100644
--- a/Gemfile
+++ b/Gemfile
@@ -163,7 +163,7 @@ gem 'diffy', '~> 3.3'
gem 'diff_match_patch', '~> 0.1.0'
# Application server
-gem 'rack', '~> 2.0.7'
+gem 'rack', '~> 2.0.9'
group :unicorn do
gem 'unicorn', '~> 5.4.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index 25bc537f70d..220735f0e27 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -173,7 +173,7 @@ GEM
concord (0.1.5)
adamantium (~> 0.2.0)
equalizer (~> 0.0.9)
- concurrent-ruby (1.1.5)
+ concurrent-ruby (1.1.6)
connection_pool (2.2.2)
contracts (0.11.0)
cork (0.3.0)
@@ -783,7 +783,7 @@ GEM
public_suffix (4.0.3)
pyu-ruby-sasl (0.0.3.3)
raabro (1.1.6)
- rack (2.0.7)
+ rack (2.0.9)
rack-accept (0.4.5)
rack (>= 0.4)
rack-attack (6.2.0)
@@ -854,17 +854,17 @@ GEM
json
recursive-open-struct (1.1.0)
redis (4.1.3)
- redis-actionpack (5.1.0)
- actionpack (>= 4.0, < 7)
- redis-rack (>= 1, < 3)
+ redis-actionpack (5.2.0)
+ actionpack (>= 5, < 7)
+ redis-rack (>= 2.1.0, < 3)
redis-store (>= 1.1.0, < 2)
redis-activesupport (5.2.0)
activesupport (>= 3, < 7)
redis-store (>= 1.3, < 2)
redis-namespace (1.6.0)
redis (>= 3.0.4)
- redis-rack (2.0.6)
- rack (>= 1.5, < 3)
+ redis-rack (2.1.2)
+ rack (>= 2.0.8, < 3)
redis-store (>= 1.2, < 2)
redis-rails (5.0.2)
redis-actionpack (>= 5.0, < 6)
@@ -1324,7 +1324,7 @@ DEPENDENCIES
prometheus-client-mmap (~> 0.10.0)
pry-byebug (~> 3.5.1)
pry-rails (~> 0.3.9)
- rack (~> 2.0.7)
+ rack (~> 2.0.9)
rack-attack (~> 6.2.0)
rack-cors (~> 1.0.6)
rack-oauth2 (~> 1.9.3)
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index f37da1b7f59..050155398ab 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -6,31 +6,32 @@ class ActiveSession
SESSION_BATCH_SIZE = 200
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
- attr_writer :session_id
-
attr_accessor :created_at, :updated_at,
:ip_address, :browser, :os,
:device_name, :device_type,
- :is_impersonated
+ :is_impersonated, :session_id
def current?(session)
return false if session_id.nil? || session.id.nil?
- session_id == session.id
+ # Rack v2.0.8+ added private_id, which uses the hash of the
+ # public_id to avoid timing attacks.
+ session_id.private_id == session.id.private_id
end
def human_device_type
device_type&.titleize
end
+ # This is not the same as Rack::Session::SessionId#public_id, but we
+ # need to preserve this for backwards compatibility.
def public_id
- encrypted_id = Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id)
- CGI.escape(encrypted_id)
+ Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id.public_id)
end
def self.set(user, request)
Gitlab::Redis::SharedState.with do |redis|
- session_id = request.session.id
+ session_id = request.session.id.public_id
client = DeviceDetector.new(request.user_agent)
timestamp = Time.current
@@ -63,32 +64,35 @@ class ActiveSession
def self.list(user)
Gitlab::Redis::SharedState.with do |redis|
- cleaned_up_lookup_entries(redis, user).map do |entry|
- # rubocop:disable Security/MarshalLoad
- Marshal.load(entry)
- # rubocop:enable Security/MarshalLoad
+ cleaned_up_lookup_entries(redis, user).map do |raw_session|
+ load_raw_session(raw_session)
end
end
end
def self.destroy(user, session_id)
+ return unless session_id
+
Gitlab::Redis::SharedState.with do |redis|
destroy_sessions(redis, user, [session_id])
end
end
def self.destroy_with_public_id(user, public_id)
- session_id = decrypt_public_id(public_id)
- destroy(user, session_id) unless session_id.nil?
+ decrypted_id = decrypt_public_id(public_id)
+
+ return if decrypted_id.nil?
+
+ session_id = Rack::Session::SessionId.new(decrypted_id)
+ destroy(user, session_id)
end
def self.destroy_sessions(redis, user, session_ids)
- key_names = session_ids.map {|session_id| key_name(user.id, session_id) }
- session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
+ key_names = session_ids.map { |session_id| key_name(user.id, session_id.public_id) }
- redis.srem(lookup_key_name(user.id), session_ids)
+ redis.srem(lookup_key_name(user.id), session_ids.map(&:public_id))
redis.del(key_names)
- redis.del(session_names)
+ redis.del(rack_session_keys(session_ids))
end
def self.cleanup(user)
@@ -110,28 +114,65 @@ class ActiveSession
sessions_from_ids(session_ids_for_user(user.id))
end
+ # Lists the relevant session IDs for the user.
+ #
+ # Returns an array of Rack::Session::SessionId objects
def self.session_ids_for_user(user_id)
Gitlab::Redis::SharedState.with do |redis|
- redis.smembers(lookup_key_name(user_id))
+ session_ids = redis.smembers(lookup_key_name(user_id))
+ session_ids.map { |id| Rack::Session::SessionId.new(id) }
end
end
+ # Lists the ActiveSession objects for the given session IDs.
+ #
+ # session_ids - An array of Rack::Session::SessionId objects
+ #
+ # Returns an array of ActiveSession objects
def self.sessions_from_ids(session_ids)
return [] if session_ids.empty?
Gitlab::Redis::SharedState.with do |redis|
- session_keys = session_ids.map { |session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
+ session_keys = rack_session_keys(session_ids)
session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch|
redis.mget(session_keys_batch).compact.map do |raw_session|
- # rubocop:disable Security/MarshalLoad
- Marshal.load(raw_session)
- # rubocop:enable Security/MarshalLoad
+ load_raw_session(raw_session)
end
end
end
end
+ # Deserializes an ActiveSession object from Redis.
+ #
+ # raw_session - Raw bytes from Redis
+ #
+ # Returns an ActiveSession object
+ def self.load_raw_session(raw_session)
+ # rubocop:disable Security/MarshalLoad
+ session = Marshal.load(raw_session)
+ # rubocop:enable Security/MarshalLoad
+
+ # Older ActiveSession models serialize `session_id` as strings, To
+ # avoid breaking older sessions, we keep backwards compatibility
+ # with older Redis keys and initiate Rack::Session::SessionId here.
+ session.session_id = Rack::Session::SessionId.new(session.session_id) if session.try(:session_id).is_a?(String)
+ session
+ end
+
+ def self.rack_session_keys(session_ids)
+ session_ids.each_with_object([]) do |session_id, arr|
+ # This is a redis-rack implementation detail
+ # (https://github.com/redis-store/redis-rack/blob/master/lib/rack/session/redis.rb#L88)
+ #
+ # We need to delete session keys based on the legacy public key name
+ # and the newer private ID keys, but there's no well-defined interface
+ # so we have to do it directly.
+ arr << "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id.public_id}"
+ arr << "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id.private_id}"
+ end
+ end
+
def self.raw_active_session_entries(redis, session_ids, user_id)
return [] if session_ids.empty?
@@ -146,7 +187,7 @@ class ActiveSession
entry_keys = raw_active_session_entries(redis, session_ids, user_id)
entry_keys.compact.map do |raw_session|
- Marshal.load(raw_session) # rubocop:disable Security/MarshalLoad
+ load_raw_session(raw_session)
end
end
@@ -159,10 +200,13 @@ class ActiveSession
sessions = active_session_entries(session_ids, user.id, redis)
sessions.sort_by! {|session| session.updated_at }.reverse!
destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
- destroyable_session_ids = destroyable_sessions.map { |session| session.send :session_id } # rubocop:disable GitlabSecurity/PublicSend
+ destroyable_session_ids = destroyable_sessions.map { |session| session.session_id }
destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any?
end
+ # Cleans up the lookup set by removing any session IDs that are no longer present.
+ #
+ # Returns an array of marshalled ActiveModel objects that are still active.
def self.cleaned_up_lookup_entries(redis, user)
session_ids = session_ids_for_user(user.id)
entries = raw_active_session_entries(redis, session_ids, user.id)
@@ -181,13 +225,8 @@ class ActiveSession
end
private_class_method def self.decrypt_public_id(public_id)
- decoded_id = CGI.unescape(public_id)
- Gitlab::CryptoHelper.aes256_gcm_decrypt(decoded_id)
+ Gitlab::CryptoHelper.aes256_gcm_decrypt(public_id)
rescue
nil
end
-
- private
-
- attr_reader :session_id
end
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index c9c6b54a791..9437eb9eede 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -29,7 +29,15 @@ module Groups
group.chat_team&.remove_mattermost_team(current_user)
+ user_ids_for_project_authorizations_refresh = group.user_ids_for_project_authorizations
+
group.destroy
+
+ UserProjectAccessChangedService
+ .new(user_ids_for_project_authorizations_refresh)
+ .execute(blocking: true)
+
+ group
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/changelogs/unreleased/213665-update_project_authorizations_on_group_delete.yml b/changelogs/unreleased/213665-update_project_authorizations_on_group_delete.yml
new file mode 100644
index 00000000000..8b7443f25ba
--- /dev/null
+++ b/changelogs/unreleased/213665-update_project_authorizations_on_group_delete.yml
@@ -0,0 +1,5 @@
+---
+title: Refresh ProjectAuthorization during Group deletion
+merge_request:
+author:
+type: security
diff --git a/changelogs/unreleased/security-update-rack-2-0-9.yml b/changelogs/unreleased/security-update-rack-2-0-9.yml
new file mode 100644
index 00000000000..2dec5d2782e
--- /dev/null
+++ b/changelogs/unreleased/security-update-rack-2-0-9.yml
@@ -0,0 +1,5 @@
+---
+title: Update rack and related gems to 2.0.9 to fix security issue
+merge_request:
+author:
+type: security
diff --git a/db/post_migrate/20200204113225_schedule_recalculate_project_authorizations_third_run.rb b/db/post_migrate/20200204113225_schedule_recalculate_project_authorizations_third_run.rb
new file mode 100644
index 00000000000..47b22b4800a
--- /dev/null
+++ b/db/post_migrate/20200204113225_schedule_recalculate_project_authorizations_third_run.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class ScheduleRecalculateProjectAuthorizationsThirdRun < ActiveRecord::Migration[5.1]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ MIGRATION = 'RecalculateProjectAuthorizationsWithMinMaxUserId'
+ BATCH_SIZE = 2_500
+ DELAY_INTERVAL = 2.minutes.to_i
+
+ disable_ddl_transaction!
+
+ class User < ActiveRecord::Base
+ include ::EachBatch
+
+ self.table_name = 'users'
+ end
+
+ def up
+ say "Scheduling #{MIGRATION} jobs"
+
+ queue_background_migration_jobs_by_range_at_intervals(User, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE)
+ end
+
+ def down
+ end
+end
diff --git a/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb b/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb
new file mode 100644
index 00000000000..19ba8984224
--- /dev/null
+++ b/spec/migrations/schedule_recalculate_project_authorizations_third_run_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20200204113225_schedule_recalculate_project_authorizations_third_run.rb')
+
+describe ScheduleRecalculateProjectAuthorizationsThirdRun do
+ let(:users_table) { table(:users) }
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 2)
+
+ 1.upto(4) do |i|
+ users_table.create!(id: i, name: "user#{i}", email: "user#{i}@example.com", projects_limit: 1)
+ end
+ end
+
+ it 'schedules background migration' do
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ expect(described_class::MIGRATION).to be_scheduled_migration(1, 2)
+ expect(described_class::MIGRATION).to be_scheduled_migration(3, 4)
+ end
+ end
+ end
+end
diff --git a/spec/models/active_session_spec.rb b/spec/models/active_session_spec.rb
index bff3ac313c4..d43c64f36f6 100644
--- a/spec/models/active_session_spec.rb
+++ b/spec/models/active_session_spec.rb
@@ -9,10 +9,8 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
end
end
- let(:session) do
- double(:session, { id: '6919a6f1bb119dd7396fadc38fd18d0d',
- '[]': {} })
- end
+ let(:rack_session) { Rack::Session::SessionId.new('6919a6f1bb119dd7396fadc38fd18d0d') }
+ let(:session) { instance_double(ActionDispatch::Request::Session, id: rack_session, '[]': {}) }
let(:request) do
double(:request, {
@@ -25,13 +23,13 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
describe '#current?' do
it 'returns true if the active session matches the current session' do
- active_session = ActiveSession.new(session_id: '6919a6f1bb119dd7396fadc38fd18d0d')
+ active_session = ActiveSession.new(session_id: rack_session)
expect(active_session.current?(session)).to be true
end
it 'returns false if the active session does not match the current session' do
- active_session = ActiveSession.new(session_id: '59822c7d9fcdfa03725eff41782ad97d')
+ active_session = ActiveSession.new(session_id: Rack::Session::SessionId.new('59822c7d9fcdfa03725eff41782ad97d'))
expect(active_session.current?(session)).to be false
end
@@ -46,14 +44,12 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
describe '#public_id' do
it 'returns an encrypted, url-encoded session id' do
- original_session_id = "!*'();:@&\n=+$,/?%abcd#123[4567]8"
+ original_session_id = Rack::Session::SessionId.new("!*'();:@&\n=+$,/?%abcd#123[4567]8")
active_session = ActiveSession.new(session_id: original_session_id)
- encrypted_encoded_id = active_session.public_id
-
- encrypted_id = CGI.unescape(encrypted_encoded_id)
+ encrypted_id = active_session.public_id
derived_session_id = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_id)
- expect(original_session_id).to eq derived_session_id
+ expect(original_session_id.public_id).to eq derived_session_id
end
end
@@ -104,7 +100,8 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
describe '.list_sessions' do
it 'uses the ActiveSession lookup to return original sessions' do
Gitlab::Redis::SharedState.with do |redis|
- redis.set("session:gitlab:6919a6f1bb119dd7396fadc38fd18d0d", Marshal.dump({ _csrf_token: 'abcd' }))
+ # Emulate redis-rack: https://github.com/redis-store/redis-rack/blob/c75f7f1a6016ee224e2615017fbfee964f23a837/lib/rack/session/redis.rb#L88
+ redis.set("session:gitlab:#{rack_session.private_id}", Marshal.dump({ _csrf_token: 'abcd' }))
redis.sadd(
"session:lookup:user:gitlab:#{user.id}",
@@ -127,17 +124,18 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
redis.sadd("session:lookup:user:gitlab:#{user.id}", session_ids)
end
- expect(ActiveSession.session_ids_for_user(user.id)).to eq(session_ids)
+ expect(ActiveSession.session_ids_for_user(user.id).map(&:to_s)).to eq(session_ids)
end
end
describe '.sessions_from_ids' do
it 'uses the ActiveSession lookup to return original sessions' do
Gitlab::Redis::SharedState.with do |redis|
- redis.set("session:gitlab:6919a6f1bb119dd7396fadc38fd18d0d", Marshal.dump({ _csrf_token: 'abcd' }))
+ # Emulate redis-rack: https://github.com/redis-store/redis-rack/blob/c75f7f1a6016ee224e2615017fbfee964f23a837/lib/rack/session/redis.rb#L88
+ redis.set("session:gitlab:#{rack_session.private_id}", Marshal.dump({ _csrf_token: 'abcd' }))
end
- expect(ActiveSession.sessions_from_ids(['6919a6f1bb119dd7396fadc38fd18d0d'])).to eq [{ _csrf_token: 'abcd' }]
+ expect(ActiveSession.sessions_from_ids([rack_session])).to eq [{ _csrf_token: 'abcd' }]
end
it 'avoids a redis lookup for an empty array' do
@@ -152,11 +150,12 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
redis = double(:redis)
expect(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis)
- sessions = %w[session-a session-b]
+ sessions = %w[session-a session-b session-c session-d]
mget_responses = sessions.map { |session| [Marshal.dump(session)]}
- expect(redis).to receive(:mget).twice.and_return(*mget_responses)
+ expect(redis).to receive(:mget).exactly(4).times.and_return(*mget_responses)
- expect(ActiveSession.sessions_from_ids([1, 2])).to eql(sessions)
+ session_ids = [1, 2].map { |id| Rack::Session::SessionId.new(id.to_s) }
+ expect(ActiveSession.sessions_from_ids(session_ids).map(&:to_s)).to eql(sessions)
end
end
@@ -212,6 +211,12 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
end
describe '.destroy' do
+ it 'gracefully handles a nil session ID' do
+ expect(described_class).not_to receive(:destroy_sessions)
+
+ ActiveSession.destroy(user, nil)
+ end
+
it 'removes the entry associated with the currently killed user session' do
Gitlab::Redis::SharedState.with do |redis|
redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '')
@@ -244,8 +249,9 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
it 'removes the devise session' do
Gitlab::Redis::SharedState.with do |redis|
- redis.set("session:user:gitlab:#{user.id}:6919a6f1bb119dd7396fadc38fd18d0d", '')
- redis.set("session:gitlab:6919a6f1bb119dd7396fadc38fd18d0d", '')
+ redis.set("session:user:gitlab:#{user.id}:#{rack_session.public_id}", '')
+ # Emulate redis-rack: https://github.com/redis-store/redis-rack/blob/c75f7f1a6016ee224e2615017fbfee964f23a837/lib/rack/session/redis.rb#L88
+ redis.set("session:gitlab:#{rack_session.private_id}", '')
end
ActiveSession.destroy(user, request.session.id)
@@ -322,7 +328,7 @@ RSpec.describe ActiveSession, :clean_gitlab_redis_shared_state do
(1..max_number_of_sessions_plus_two).each do |number|
redis.set(
"session:user:gitlab:#{user.id}:#{number}",
- Marshal.dump(ActiveSession.new(session_id: "#{number}", updated_at: number.days.ago))
+ Marshal.dump(ActiveSession.new(session_id: number.to_s, updated_at: number.days.ago))
)
redis.sadd(
"session:lookup:user:gitlab:#{user.id}",
diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb
index a45c7cdffa6..bf639153b99 100644
--- a/spec/services/groups/destroy_service_spec.rb
+++ b/spec/services/groups/destroy_service_spec.rb
@@ -133,4 +133,55 @@ describe Groups::DestroyService do
end
end
end
+
+ describe 'authorization updates', :sidekiq_inline do
+ context 'shared groups' do
+ let!(:shared_group) { create(:group, :private) }
+ let!(:shared_group_child) { create(:group, :private, parent: shared_group) }
+
+ let!(:project) { create(:project, group: shared_group) }
+ let!(:project_child) { create(:project, group: shared_group_child) }
+
+ before do
+ create(:group_group_link, shared_group: shared_group, shared_with_group: group)
+ group.refresh_members_authorized_projects
+ end
+
+ it 'updates project authorization' do
+ expect(user.can?(:read_project, project)).to eq(true)
+ expect(user.can?(:read_project, project_child)).to eq(true)
+
+ destroy_group(group, user, false)
+
+ expect(user.can?(:read_project, project)).to eq(false)
+ expect(user.can?(:read_project, project_child)).to eq(false)
+ end
+ end
+
+ context 'shared groups in the same group hierarchy' do
+ let!(:subgroup) { create(:group, :private, parent: group) }
+ let!(:subgroup_user) { create(:user) }
+
+ before do
+ subgroup.add_user(subgroup_user, Gitlab::Access::MAINTAINER)
+
+ create(:group_group_link, shared_group: group, shared_with_group: subgroup)
+ subgroup.refresh_members_authorized_projects
+ end
+
+ context 'group is deleted' do
+ it 'updates project authorization' do
+ expect { destroy_group(group, user, false) }.to(
+ change { subgroup_user.can?(:read_project, project) }.from(true).to(false))
+ end
+ end
+
+ context 'subgroup is deleted' do
+ it 'updates project authorization' do
+ expect { destroy_group(subgroup, user, false) }.to(
+ change { subgroup_user.can?(:read_project, project) }.from(true).to(false))
+ end
+ end
+ end
+ end
end
diff --git a/spec/support/rails/test_case_patch.rb b/spec/support/rails/test_case_patch.rb
new file mode 100644
index 00000000000..161e1ef2a4c
--- /dev/null
+++ b/spec/support/rails/test_case_patch.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+#
+# This file pulls in the changes in https://github.com/rails/rails/pull/38063
+# to fix controller specs updated with the latest Rack versions.
+#
+# This file should be removed after that change ships. It is not
+# present in Rails 6.0.2.2.
+module ActionController
+ class TestRequest < ActionDispatch::TestRequest #:nodoc:
+ def self.new_session
+ TestSessionPatched.new
+ end
+ end
+
+ # Methods #destroy and #load! are overridden to avoid calling methods on the
+ # @store object, which does not exist for the TestSession class.
+ class TestSessionPatched < Rack::Session::Abstract::PersistedSecure::SecureSessionHash #:nodoc:
+ DEFAULT_OPTIONS = Rack::Session::Abstract::Persisted::DEFAULT_OPTIONS
+
+ def initialize(session = {})
+ super(nil, nil)
+ @id = Rack::Session::SessionId.new(SecureRandom.hex(16))
+ @data = stringify_keys(session)
+ @loaded = true
+ end
+
+ def exists?
+ true
+ end
+
+ def keys
+ @data.keys
+ end
+
+ def values
+ @data.values
+ end
+
+ def destroy
+ clear
+ end
+
+ def fetch(key, *args, &block)
+ @data.fetch(key.to_s, *args, &block)
+ end
+
+ private
+
+ def load!
+ @id
+ end
+ end
+end