Commit fd5637c2 authored by Douglas Barbosa Alexandre's avatar Douglas Barbosa Alexandre

Merge branch 'cross-database-modification-txn-in-wrong-db' into 'master'

Detect modification in wrong DB transaction

See merge request gitlab-org/gitlab!76717
parents fbcfd1c3 fa5c5e47
...@@ -4,6 +4,7 @@ class ApplicationRecord < ActiveRecord::Base ...@@ -4,6 +4,7 @@ class ApplicationRecord < ActiveRecord::Base
include DatabaseReflection include DatabaseReflection
include Transactions include Transactions
include LegacyBulkInsert include LegacyBulkInsert
include CrossDatabaseModification
self.abstract_class = true self.abstract_class = true
......
# frozen_string_literal: true
module CrossDatabaseModification
extend ActiveSupport::Concern
class TransactionStackTrackRecord
def initialize(subject, gitlab_schema)
@subject = subject
@gitlab_schema = gitlab_schema
@subject.gitlab_transactions_stack.push(gitlab_schema)
end
def done!
unless @done
@done = true
@subject.gitlab_transactions_stack.pop
end
true
end
def trigger_transactional_callbacks?
false
end
def before_committed!
end
def rolledback!(force_restore_state: false, should_run_callbacks: true)
done!
end
def committed!(should_run_callbacks: true)
done!
end
end
included do
private_class_method :gitlab_schema
end
class_methods do
def gitlab_transactions_stack
Thread.current[:gitlab_transactions_stack] ||= []
end
def transaction(**options, &block)
if track_gitlab_schema_in_current_transaction?
super(**options) do
# Hook into current transaction to ensure that once
# the `COMMIT` is executed the `gitlab_transactions_stack`
# will be allowing to execute `after_commit_queue`
record = TransactionStackTrackRecord.new(self, gitlab_schema)
begin
connection.current_transaction.add_record(record)
yield
ensure
record.done!
end
end
else
super(**options, &block)
end
end
def track_gitlab_schema_in_current_transaction?
return false unless Feature::FlipperFeature.table_exists?
Feature.enabled?(:track_gitlab_schema_in_current_transaction, default_enabled: :yaml)
rescue ActiveRecord::NoDatabaseError, PG::ConnectionBad
false
end
def gitlab_schema
case self.name
when 'ActiveRecord::Base', 'ApplicationRecord'
:gitlab_main
when 'Ci::ApplicationRecord'
:gitlab_ci
else
Gitlab::Database::GitlabSchema.table_schema(table_name) if table_name
end
end
end
end
---
name: track_gitlab_schema_in_current_transaction
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76717
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/349944
milestone: '14.8'
type: development
group: group::sharding
default_enabled: false
...@@ -32,9 +32,11 @@ module Security ...@@ -32,9 +32,11 @@ module Security
end end
def execute def execute
::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/350092') do
ApplicationRecord.transaction do ApplicationRecord.transaction do
TASKS.each { |task| execute_task(task) } TASKS.each { |task| execute_task(task) }
end end
end
@finding_maps.map(&:vulnerability_id) @finding_maps.map(&:vulnerability_id)
end end
......
...@@ -361,11 +361,9 @@ RSpec.describe Ci::Pipeline do ...@@ -361,11 +361,9 @@ RSpec.describe Ci::Pipeline do
end end
context 'when pipeline project has downstream subscriptions' do context 'when pipeline project has downstream subscriptions' do
let(:pipeline) { create(:ci_empty_pipeline, project: create(:project, :public)) } let(:downstream_project) { create(:project) }
let(:project) { create(:project, :public, downstream_projects: [downstream_project]) }
before do let(:pipeline) { create(:ci_empty_pipeline, project: project) }
pipeline.project.downstream_projects << create(:project)
end
context 'when pipeline runs on a tag' do context 'when pipeline runs on a tag' do
before do before do
......
...@@ -3231,9 +3231,8 @@ RSpec.describe Project do ...@@ -3231,9 +3231,8 @@ RSpec.describe Project do
describe '#upstream_projects' do describe '#upstream_projects' do
it 'returns the upstream projects' do it 'returns the upstream projects' do
primary_project = create(:project, :public)
upstream_project = create(:project, :public) upstream_project = create(:project, :public)
primary_project.upstream_projects << upstream_project primary_project = create(:project, :public, upstream_projects: [upstream_project])
with_cross_joins_prevented do with_cross_joins_prevented do
expect(primary_project.upstream_projects).to eq([upstream_project]) expect(primary_project.upstream_projects).to eq([upstream_project])
...@@ -3243,9 +3242,8 @@ RSpec.describe Project do ...@@ -3243,9 +3242,8 @@ RSpec.describe Project do
describe '#upstream_projects_count' do describe '#upstream_projects_count' do
it 'returns the upstream projects count' do it 'returns the upstream projects count' do
primary_project = create(:project, :public)
upstream_projects = create_list(:project, 2, :public) upstream_projects = create_list(:project, 2, :public)
primary_project.upstream_projects = upstream_projects primary_project = create(:project, :public, upstream_projects: upstream_projects)
with_cross_joins_prevented do with_cross_joins_prevented do
expect(primary_project.upstream_projects_count).to eq(2) expect(primary_project.upstream_projects_count).to eq(2)
...@@ -3255,9 +3253,8 @@ RSpec.describe Project do ...@@ -3255,9 +3253,8 @@ RSpec.describe Project do
describe '#downstream_projects' do describe '#downstream_projects' do
it 'returns the downstream projects' do it 'returns the downstream projects' do
primary_project = create(:project, :public)
downstream_project = create(:project, :public) downstream_project = create(:project, :public)
primary_project.downstream_projects << downstream_project primary_project = create(:project, :public, downstream_projects: [downstream_project])
with_cross_joins_prevented do with_cross_joins_prevented do
expect(primary_project.downstream_projects).to eq([downstream_project]) expect(primary_project.downstream_projects).to eq([downstream_project])
...@@ -3267,9 +3264,8 @@ RSpec.describe Project do ...@@ -3267,9 +3264,8 @@ RSpec.describe Project do
describe '#downstream_projects_count' do describe '#downstream_projects_count' do
it 'returns the downstream projects count' do it 'returns the downstream projects count' do
primary_project = create(:project, :public)
downstream_projects = create_list(:project, 2, :public) downstream_projects = create_list(:project, 2, :public)
primary_project.downstream_projects = downstream_projects primary_project = create(:project, :public, downstream_projects: downstream_projects)
with_cross_joins_prevented do with_cross_joins_prevented do
expect(primary_project.downstream_projects_count).to eq(2) expect(primary_project.downstream_projects_count).to eq(2)
......
...@@ -87,6 +87,8 @@ module Gitlab ...@@ -87,6 +87,8 @@ module Gitlab
all_tables = context[:modified_tables_by_db].values.map(&:to_a).flatten all_tables = context[:modified_tables_by_db].values.map(&:to_a).flatten
schemas = ::Gitlab::Database::GitlabSchema.table_schemas(all_tables) schemas = ::Gitlab::Database::GitlabSchema.table_schemas(all_tables)
schemas += ApplicationRecord.gitlab_transactions_stack
if schemas.many? if schemas.many?
message = "Cross-database data modification of '#{schemas.to_a.join(", ")}' were detected within " \ message = "Cross-database data modification of '#{schemas.to_a.join(", ")}' were detected within " \
"a transaction modifying the '#{all_tables.to_a.join(", ")}' tables." \ "a transaction modifying the '#{all_tables.to_a.join(", ")}' tables." \
......
...@@ -14,10 +14,12 @@ module Gitlab ...@@ -14,10 +14,12 @@ module Gitlab
# It also adds some logic around Group Labels/Milestones for edge cases. # It also adds some logic around Group Labels/Milestones for edge cases.
class ObjectBuilder < Base::ObjectBuilder class ObjectBuilder < Base::ObjectBuilder
def self.build(*args) def self.build(*args)
::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.allow_cross_database_modification_within_transaction(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/350091') do
::Project.transaction do ::Project.transaction do
super super
end end
end end
end
def initialize(klass, attributes) def initialize(klass, attributes)
super super
......
...@@ -15,8 +15,9 @@ RSpec.describe Resolvers::PackagePipelinesResolver do ...@@ -15,8 +15,9 @@ RSpec.describe Resolvers::PackagePipelinesResolver do
subject { resolve(described_class, obj: package, args: args, ctx: { current_user: user }) } subject { resolve(described_class, obj: package, args: args, ctx: { current_user: user }) }
before do before do
package.pipelines = pipelines pipelines.each do |pipeline|
package.save! create(:package_build_info, package: package, pipeline: pipeline)
end
end end
it { is_expected.to contain_exactly(*pipelines) } it { is_expected.to contain_exactly(*pipelines) }
......
...@@ -14,23 +14,25 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio ...@@ -14,23 +14,25 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio
Gitlab::Database::QueryAnalyzer.instance.within { example.run } Gitlab::Database::QueryAnalyzer.instance.within { example.run }
end end
shared_examples 'successful examples' do shared_examples 'successful examples' do |model:|
let(:model) { model }
context 'outside transaction' do context 'outside transaction' do
it { expect { run_queries }.not_to raise_error } it { expect { run_queries }.not_to raise_error }
end end
context 'within transaction' do context "within #{model} transaction" do
it do it do
Project.transaction do model.transaction do
expect { run_queries }.not_to raise_error expect { run_queries }.not_to raise_error
end end
end end
end end
context 'within nested transaction' do context "within nested #{model} transaction" do
it do it do
Project.transaction(requires_new: true) do model.transaction(requires_new: true) do
Project.transaction(requires_new: true) do model.transaction(requires_new: true) do
expect { run_queries }.not_to raise_error expect { run_queries }.not_to raise_error
end end
end end
...@@ -38,13 +40,26 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio ...@@ -38,13 +40,26 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio
end end
end end
shared_examples 'cross-database modification errors' do |model:|
let(:model) { model }
context "within #{model} transaction" do
it 'raises error' do
model.transaction do
expect { run_queries }.to raise_error /Cross-database data modification/
end
end
end
end
context 'when CI and other tables are read in a transaction' do context 'when CI and other tables are read in a transaction' do
def run_queries def run_queries
pipeline.reload pipeline.reload
project.reload project.reload
end end
include_examples 'successful examples' include_examples 'successful examples', model: Project
include_examples 'successful examples', model: Ci::Pipeline
end end
context 'when only CI data is modified' do context 'when only CI data is modified' do
...@@ -53,7 +68,9 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio ...@@ -53,7 +68,9 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio
project.reload project.reload
end end
include_examples 'successful examples' include_examples 'successful examples', model: Ci::Pipeline
include_examples 'cross-database modification errors', model: Project
end end
context 'when other data is modified' do context 'when other data is modified' do
...@@ -62,7 +79,9 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio ...@@ -62,7 +79,9 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio
project.touch project.touch
end end
include_examples 'successful examples' include_examples 'successful examples', model: Project
include_examples 'cross-database modification errors', model: Ci::Pipeline
end end
context 'when both CI and other data is modified' do context 'when both CI and other data is modified' do
...@@ -144,7 +163,9 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio ...@@ -144,7 +163,9 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModificatio
project.save! project.save!
end end
include_examples 'successful examples' include_examples 'successful examples', model: Ci::Pipeline
include_examples 'cross-database modification errors', model: Project
end end
describe '.allow_cross_database_modification_within_transaction' do describe '.allow_cross_database_modification_within_transaction' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe CrossDatabaseModification do
describe '.transaction' do
context 'feature flag disabled' do
before do
stub_feature_flags(track_gitlab_schema_in_current_transaction: false)
end
it 'does not add to gitlab_transactions_stack' do
ApplicationRecord.transaction do
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
Project.first
end
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
end
end
context 'feature flag is not yet setup' do
before do
allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError)
end
it 'does not add to gitlab_transactions_stack' do
ApplicationRecord.transaction do
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
Project.first
end
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
end
end
it 'adds the current gitlab schema to gitlab_transactions_stack', :aggregate_failures do
ApplicationRecord.transaction do
expect(ApplicationRecord.gitlab_transactions_stack).to contain_exactly(:gitlab_main)
Project.first
end
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
Ci::ApplicationRecord.transaction do
expect(ApplicationRecord.gitlab_transactions_stack).to contain_exactly(:gitlab_ci)
Project.first
end
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
Project.transaction do
expect(ApplicationRecord.gitlab_transactions_stack).to contain_exactly(:gitlab_main)
Project.first
end
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
Ci::Pipeline.transaction do
expect(ApplicationRecord.gitlab_transactions_stack).to contain_exactly(:gitlab_ci)
Project.first
end
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
ApplicationRecord.transaction do
expect(ApplicationRecord.gitlab_transactions_stack).to contain_exactly(:gitlab_main)
Ci::Pipeline.transaction do
expect(ApplicationRecord.gitlab_transactions_stack).to contain_exactly(:gitlab_main, :gitlab_ci)
Project.first
end
end
expect(ApplicationRecord.gitlab_transactions_stack).to be_empty
end
it 'yields' do
expect { |block| ApplicationRecord.transaction(&block) }.to yield_control
end
end
end
...@@ -145,8 +145,9 @@ RSpec.describe 'package details' do ...@@ -145,8 +145,9 @@ RSpec.describe 'package details' do
let(:pipeline_gids) { pipelines.sort_by(&:id).map(&:to_gid).map(&:to_s).reverse } let(:pipeline_gids) { pipelines.sort_by(&:id).map(&:to_gid).map(&:to_s).reverse }
before do before do
composer_package.pipelines = pipelines pipelines.each do |pipeline|
composer_package.save! create(:package_build_info, package: composer_package, pipeline: pipeline)
end
end end
def run_query(args) def run_query(args)
......
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