From 900bfd94a9c3c45484d88aa69071b7a52c5b04b4 Mon Sep 17 00:00:00 2001 From: schneems Date: Fri, 14 Aug 2015 11:31:33 -0500 Subject: Prevent destructive action on production database This PR introduces a key/value type store to Active Record that can be used for storing internal values. It is an alternative implementation to #21237 cc @sgrif @matthewd. It is possible to run your tests against your production database by accident right now. While infrequently, but as an anecdotal data point, Heroku receives a non-trivial number of requests for a database restore due to this happening. In these cases the loss can be large. To prevent against running tests against production we can store the "environment" version that was used when migrating the database in a new internal table. Before executing tests we can see if the database is a listed in `protected_environments` and abort. There is a manual escape valve to force this check from happening with environment variable `DISABLE_DATABASE_ENVIRONMENT_CHECK=1`. --- .../abstract/schema_statements.rb | 4 ++ .../lib/active_record/internal_metadata.rb | 49 ++++++++++++++++++++++ activerecord/lib/active_record/migration.rb | 37 +++++++++++++++- activerecord/lib/active_record/model_schema.rb | 13 ++++++ .../lib/active_record/railties/databases.rake | 18 +++++--- activerecord/lib/active_record/schema.rb | 3 ++ activerecord/lib/active_record/schema_dumper.rb | 2 +- .../lib/active_record/tasks/database_tasks.rb | 9 ++++ 8 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 activerecord/lib/active_record/internal_metadata.rb (limited to 'activerecord/lib/active_record') diff --git a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb index b50d28862c..ac77011b83 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -969,6 +969,10 @@ module ActiveRecord ActiveRecord::SchemaMigration.create_table end + def initialize_internal_metadata_table + ActiveRecord::InternalMetadata.create_table + end + def assume_migrated_upto_version(version, migrations_paths) migrations_paths = Array(migrations_paths) version = version.to_i diff --git a/activerecord/lib/active_record/internal_metadata.rb b/activerecord/lib/active_record/internal_metadata.rb new file mode 100644 index 0000000000..e907bd0563 --- /dev/null +++ b/activerecord/lib/active_record/internal_metadata.rb @@ -0,0 +1,49 @@ +require 'active_record/scoping/default' +require 'active_record/scoping/named' + +module ActiveRecord + # This class is used to create a table that keeps track of values and keys such + # as which environment migrations were run in. + class InternalMetadata < ActiveRecord::Base + class << self + def primary_key + "key" + end + + def table_name + "#{table_name_prefix}#{ActiveRecord::Base.internal_metadata_table_name}#{table_name_suffix}" + end + + def index_name + "#{table_name_prefix}unique_#{ActiveRecord::Base.internal_metadata_table_name}#{table_name_suffix}" + end + + def store(hash) + hash.each do |key, value| + first_or_initialize(key: key).update_attributes!(value: value) + end + end + + def value_for(key) + where(key: key).pluck(:value).first + end + + def table_exists? + ActiveSupport::Deprecation.silence { connection.table_exists?(table_name) } + end + + # Creates a internal metadata table with columns +key+ and +value+ + def create_table + unless table_exists? + connection.create_table(table_name, id: false) do |t| + t.column :key, :string + t.column :value, :string + t.timestamps + end + + connection.add_index table_name, :key, unique: true, name: index_name + end + end + end + end +end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 43726d795e..c61491d58c 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -143,6 +143,26 @@ module ActiveRecord end end + class NoEnvironmentInSchemaError < MigrationError #:nodoc: + def initialize + msg = "Environment data not found in the schema. To resolve this issue, run: \n\n\tbin/rake db:migrate" + if defined?(Rails.env) + super("#{msg} RAILS_ENV=#{::Rails.env}") + else + super(msg) + end + end + end + + class ProtectedEnvironmentError < ActiveRecordError #:nodoc: + def initialize(env = "production") + msg = "You are attempting to run a destructive action against your '#{env}' database\n" + msg << "if you are sure you want to continue, run the same command with the environment variable\n" + msg << "DISABLE_DATABASE_ENVIRONMENT_CHECK=1" + super(msg) + end + end + # = Active Record Migrations # # Migrations can manage the evolution of a schema used by several physical @@ -1078,6 +1098,7 @@ module ActiveRecord validate(@migrations) Base.connection.initialize_schema_migrations_table + Base.connection.initialize_internal_metadata_table end def current_version @@ -1202,10 +1223,24 @@ module ActiveRecord ActiveRecord::SchemaMigration.where(:version => version.to_s).delete_all else migrated << version - ActiveRecord::SchemaMigration.create!(:version => version.to_s) + ActiveRecord::SchemaMigration.create!(version: version.to_s) + environment = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + ActiveRecord::InternalMetadata.store(environment: environment) end end + def self.last_stored_environment + ActiveRecord::InternalMetadata.value_for(:environment) + end + + def self.protected_environment? + return false if current_version == 0 + raise NoEnvironmentInSchemaError unless ActiveRecord::InternalMetadata.table_exists? + + raise NoEnvironmentInSchemaError unless last_stored_environment + ActiveRecord::Base.protected_environments.include?(last_stored_environment) + end + def up? @direction == :up end diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index 5df67cdbe7..b82923494f 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -42,6 +42,19 @@ module ActiveRecord class_attribute :schema_migrations_table_name, instance_accessor: false self.schema_migrations_table_name = "schema_migrations" + ## + # :singleton-method: + # Accessor for the name of the internal metadata table. By default, the value is "active_record_internal_metadatas" + class_attribute :internal_metadata_table_name, instance_accessor: false + self.internal_metadata_table_name = "active_record_internal_metadatas" + + ## + # :singleton-method: + # Accessor for an array of names of environments where destructive actions should be prohibited. By default, + # the value is ["production"] + class_attribute :protected_environments, instance_accessor: false + self.protected_environments = ["production"] + ## # :singleton-method: # Indicates whether table names should be the pluralized versions of the corresponding class names. diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index 5b1ea16f0b..c1203fb745 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -1,6 +1,10 @@ require 'active_record' db_namespace = namespace :db do + task :check_protected_environments => [:environment, :load_config] do + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + end + task :load_config do ActiveRecord::Base.configurations = ActiveRecord::Tasks::DatabaseTasks.database_configuration || {} ActiveRecord::Migrator.migrations_paths = ActiveRecord::Tasks::DatabaseTasks.migrations_paths @@ -18,24 +22,28 @@ db_namespace = namespace :db do end namespace :drop do - task :all => :load_config do + task :all => [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.drop_all end end desc 'Drops the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:drop:all to drop all databases in the config). Without RAILS_ENV, it defaults to dropping the development and test databases.' - task :drop => [:load_config] do + task :drop => [:load_config, :check_protected_environments] do + db_namespace["drop:_unsafe"].invoke + end + + task "drop:_unsafe" => [:load_config] do ActiveRecord::Tasks::DatabaseTasks.drop_current end namespace :purge do - task :all => :load_config do + task :all => [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.purge_all end end # desc "Empty the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use db:purge:all to purge all databases in the config). Without RAILS_ENV it defaults to purging the development and test databases." - task :purge => [:load_config] do + task :purge => [:load_config, :check_protected_environments] do ActiveRecord::Tasks::DatabaseTasks.purge_current end @@ -351,7 +359,7 @@ db_namespace = namespace :db do task :clone_structure => %w(db:test:deprecated db:structure:dump db:test:load_structure) # desc "Empty the test database" - task :purge => %w(environment load_config) do + task :purge => %w(environment load_config check_protected_environments) do ActiveRecord::Tasks::DatabaseTasks.purge ActiveRecord::Base.configurations['test'] end diff --git a/activerecord/lib/active_record/schema.rb b/activerecord/lib/active_record/schema.rb index fdf9965a82..0a0ea33196 100644 --- a/activerecord/lib/active_record/schema.rb +++ b/activerecord/lib/active_record/schema.rb @@ -51,6 +51,9 @@ module ActiveRecord initialize_schema_migrations_table connection.assume_migrated_upto_version(info[:version], migrations_paths) end + + ActiveRecord::InternalMetadata.create_table + ActiveRecord::InternalMetadata.store("environment" => ActiveRecord::ConnectionHandling::DEFAULT_ENV.call) end private diff --git a/activerecord/lib/active_record/schema_dumper.rb b/activerecord/lib/active_record/schema_dumper.rb index 2362dae9fc..65005bd44b 100644 --- a/activerecord/lib/active_record/schema_dumper.rb +++ b/activerecord/lib/active_record/schema_dumper.rb @@ -254,7 +254,7 @@ HEADER end def ignored?(table_name) - [ActiveRecord::Base.schema_migrations_table_name, ignore_tables].flatten.any? do |ignored| + [ActiveRecord::Base.schema_migrations_table_name, ActiveRecord::Base.internal_metadata_table_name, ignore_tables].flatten.any? do |ignored| ignored === remove_prefix_and_suffix(table_name) end end diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index c0c29a618c..6318a0725d 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -42,6 +42,13 @@ module ActiveRecord LOCAL_HOSTS = ['127.0.0.1', 'localhost'] + def check_protected_environments! + if !ENV['DISABLE_DATABASE_internal_metadata'] && ActiveRecord::Migrator.protected_environment? + raise ActiveRecord::ProtectedEnvironmentError.new(ActiveRecord::Migrator.last_stored_environment) + end + rescue ActiveRecord::NoDatabaseError + end + def register_task(pattern, task) @tasks ||= {} @tasks[pattern] = task @@ -204,6 +211,8 @@ module ActiveRecord else raise ArgumentError, "unknown format #{format.inspect}" end + ActiveRecord::InternalMetadata.create_table + ActiveRecord::InternalMetata.store("environment" => ActiveRecord::ConnectionHandling::DEFAULT_ENV.call) end def load_schema_for(*args) -- cgit v1.2.3 From 921997675258147fdc2bbbca0da4fb0c771c218d Mon Sep 17 00:00:00 2001 From: schneems Date: Thu, 7 Jan 2016 17:12:26 -0600 Subject: [ci skip] Add comment to remove silenced code. --- .../lib/active_record/connection_adapters/abstract_mysql_adapter.rb | 1 + 1 file changed, 1 insertion(+) (limited to 'activerecord/lib/active_record') diff --git a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb index 25ba42e5c9..2bc736d63f 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -521,6 +521,7 @@ module ActiveRecord end def table_exists?(table_name) + # Update lib/active_record/internal_metadata.rb when this gets removed ActiveSupport::Deprecation.warn(<<-MSG.squish) #table_exists? currently checks both tables and views. This behavior is deprecated and will be changed with Rails 5.1 to only check tables. -- cgit v1.2.3 From 350ae6cdc1ea83e21c23abd10e7e99c9a0bbdbd2 Mon Sep 17 00:00:00 2001 From: schneems Date: Thu, 7 Jan 2016 17:12:58 -0600 Subject: Use `key` as primary key in schema. --- activerecord/lib/active_record/internal_metadata.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'activerecord/lib/active_record') diff --git a/activerecord/lib/active_record/internal_metadata.rb b/activerecord/lib/active_record/internal_metadata.rb index e907bd0563..fc1bb0bdce 100644 --- a/activerecord/lib/active_record/internal_metadata.rb +++ b/activerecord/lib/active_record/internal_metadata.rb @@ -35,7 +35,7 @@ module ActiveRecord # Creates a internal metadata table with columns +key+ and +value+ def create_table unless table_exists? - connection.create_table(table_name, id: false) do |t| + connection.create_table(table_name, primary_key: :key, id: false ) do |t| t.column :key, :string t.column :value, :string t.timestamps -- cgit v1.2.3 From a76c4233a9ee9ffbf413c4b8353e73e8ffbeb3a5 Mon Sep 17 00:00:00 2001 From: schneems Date: Thu, 7 Jan 2016 17:40:38 -0600 Subject: Add EnvironmentMismatchError Raise an error when a destructive action is made on a database where the current environment is different from the environment stored in the database. --- activerecord/lib/active_record/migration.rb | 16 ++++++++++++++-- activerecord/lib/active_record/tasks/database_tasks.rb | 13 +++++++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) (limited to 'activerecord/lib/active_record') diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index c61491d58c..a7e747a482 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -163,6 +163,15 @@ module ActiveRecord end end + class EnvironmentMismatchError < ActiveRecordError + def initialize(current: current, stored: stored) + msg = "You are attempting to modify a database that was last run in #{ stored } environment.\n" + msg << "You are running in #{ current } environment." + msg << "if you are sure you want to continue, run the same command with the environment variable\n" + msg << "DISABLE_DATABASE_ENVIRONMENT_CHECK=1" + end + end + # = Active Record Migrations # # Migrations can manage the evolution of a schema used by several physical @@ -1224,8 +1233,7 @@ module ActiveRecord else migrated << version ActiveRecord::SchemaMigration.create!(version: version.to_s) - environment = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call - ActiveRecord::InternalMetadata.store(environment: environment) + ActiveRecord::InternalMetadata.store(environment: current_environment) end end @@ -1233,6 +1241,10 @@ module ActiveRecord ActiveRecord::InternalMetadata.value_for(:environment) end + def current_environment + ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + end + def self.protected_environment? return false if current_version == 0 raise NoEnvironmentInSchemaError unless ActiveRecord::InternalMetadata.table_exists? diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 6318a0725d..92f6f44de9 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -43,8 +43,17 @@ module ActiveRecord LOCAL_HOSTS = ['127.0.0.1', 'localhost'] def check_protected_environments! - if !ENV['DISABLE_DATABASE_internal_metadata'] && ActiveRecord::Migrator.protected_environment? - raise ActiveRecord::ProtectedEnvironmentError.new(ActiveRecord::Migrator.last_stored_environment) + unless ENV['DISABLE_DATABASE_internal_metadata'] + current = ActiveRecord::Migrator.current_environment + stored = ActiveRecord::Migrator.last_stored_environment + + if ActiveRecord::Migrator.protected_environment? + raise ActiveRecord::ProtectedEnvironmentError.new(stored) + end + + if current != stored + raise EnvironmentMismatchError.new(current: current, stored: stored) + end end rescue ActiveRecord::NoDatabaseError end -- cgit v1.2.3 From de2cb20117af68cef4126d8998cc63e178c58187 Mon Sep 17 00:00:00 2001 From: schneems Date: Fri, 8 Jan 2016 09:27:25 -0600 Subject: Use hash like syntax for InternalMetadata Discussion: https://github.com/rails/rails/pull/22967#discussion_r49137035 --- activerecord/lib/active_record/internal_metadata.rb | 10 ++++------ activerecord/lib/active_record/migration.rb | 6 +++--- activerecord/lib/active_record/schema.rb | 2 +- activerecord/lib/active_record/tasks/database_tasks.rb | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) (limited to 'activerecord/lib/active_record') diff --git a/activerecord/lib/active_record/internal_metadata.rb b/activerecord/lib/active_record/internal_metadata.rb index fc1bb0bdce..7bd66028b6 100644 --- a/activerecord/lib/active_record/internal_metadata.rb +++ b/activerecord/lib/active_record/internal_metadata.rb @@ -4,7 +4,7 @@ require 'active_record/scoping/named' module ActiveRecord # This class is used to create a table that keeps track of values and keys such # as which environment migrations were run in. - class InternalMetadata < ActiveRecord::Base + class InternalMetadata < ActiveRecord::Base # :nodoc: class << self def primary_key "key" @@ -18,13 +18,11 @@ module ActiveRecord "#{table_name_prefix}unique_#{ActiveRecord::Base.internal_metadata_table_name}#{table_name_suffix}" end - def store(hash) - hash.each do |key, value| - first_or_initialize(key: key).update_attributes!(value: value) - end + def []=(key, value) + first_or_initialize(key: key).update_attributes!(value: value) end - def value_for(key) + def [](key) where(key: key).pluck(:value).first end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index a7e747a482..cd1bda3324 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -1233,15 +1233,15 @@ module ActiveRecord else migrated << version ActiveRecord::SchemaMigration.create!(version: version.to_s) - ActiveRecord::InternalMetadata.store(environment: current_environment) + ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Migrator.current_environment end end def self.last_stored_environment - ActiveRecord::InternalMetadata.value_for(:environment) + ActiveRecord::InternalMetadata[:environment] end - def current_environment + def self.current_environment ActiveRecord::ConnectionHandling::DEFAULT_ENV.call end diff --git a/activerecord/lib/active_record/schema.rb b/activerecord/lib/active_record/schema.rb index 0a0ea33196..784a02d2c3 100644 --- a/activerecord/lib/active_record/schema.rb +++ b/activerecord/lib/active_record/schema.rb @@ -53,7 +53,7 @@ module ActiveRecord end ActiveRecord::InternalMetadata.create_table - ActiveRecord::InternalMetadata.store("environment" => ActiveRecord::ConnectionHandling::DEFAULT_ENV.call) + ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Migrator.current_environment end private diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 92f6f44de9..d51f38c93f 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -221,7 +221,7 @@ module ActiveRecord raise ArgumentError, "unknown format #{format.inspect}" end ActiveRecord::InternalMetadata.create_table - ActiveRecord::InternalMetata.store("environment" => ActiveRecord::ConnectionHandling::DEFAULT_ENV.call) + ActiveRecord::InternalMetadata[:environment] = ActiveRecord::Migrator.current_environment end def load_schema_for(*args) -- cgit v1.2.3 From f6628adc11e2e57db75030fca9bae035be5cd95b Mon Sep 17 00:00:00 2001 From: schneems Date: Fri, 8 Jan 2016 10:05:38 -0600 Subject: Fix kwarg to not have circular dependency --- activerecord/lib/active_record/migration.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'activerecord/lib/active_record') diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index cd1bda3324..e06a581094 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -164,7 +164,7 @@ module ActiveRecord end class EnvironmentMismatchError < ActiveRecordError - def initialize(current: current, stored: stored) + def initialize(current: , stored: ) msg = "You are attempting to modify a database that was last run in #{ stored } environment.\n" msg << "You are running in #{ current } environment." msg << "if you are sure you want to continue, run the same command with the environment variable\n" -- cgit v1.2.3 From d70c68d76abcbc24ef0e56b7a7b580d0013255dd Mon Sep 17 00:00:00 2001 From: schneems Date: Fri, 8 Jan 2016 11:53:25 -0600 Subject: Fixing tests and re-locating error checking. --- activerecord/lib/active_record/migration.rb | 15 ++++++++------- activerecord/lib/active_record/tasks/database_tasks.rb | 4 ++-- 2 files changed, 10 insertions(+), 9 deletions(-) (limited to 'activerecord/lib/active_record') diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index e06a581094..f5753fe426 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -164,7 +164,7 @@ module ActiveRecord end class EnvironmentMismatchError < ActiveRecordError - def initialize(current: , stored: ) + def initialize(current: nil, stored: nil) msg = "You are attempting to modify a database that was last run in #{ stored } environment.\n" msg << "You are running in #{ current } environment." msg << "if you are sure you want to continue, run the same command with the environment variable\n" @@ -1238,7 +1238,12 @@ module ActiveRecord end def self.last_stored_environment - ActiveRecord::InternalMetadata[:environment] + return nil if current_version == 0 + raise NoEnvironmentInSchemaError unless ActiveRecord::InternalMetadata.table_exists? + + environment = ActiveRecord::InternalMetadata[:environment] + raise NoEnvironmentInSchemaError unless environment + environment end def self.current_environment @@ -1246,11 +1251,7 @@ module ActiveRecord end def self.protected_environment? - return false if current_version == 0 - raise NoEnvironmentInSchemaError unless ActiveRecord::InternalMetadata.table_exists? - - raise NoEnvironmentInSchemaError unless last_stored_environment - ActiveRecord::Base.protected_environments.include?(last_stored_environment) + ActiveRecord::Base.protected_environments.include?(last_stored_environment) if last_stored_environment end def up? diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index d51f38c93f..7b8f27699a 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -51,8 +51,8 @@ module ActiveRecord raise ActiveRecord::ProtectedEnvironmentError.new(stored) end - if current != stored - raise EnvironmentMismatchError.new(current: current, stored: stored) + if stored && stored != current + raise ActiveRecord::EnvironmentMismatchError.new(current: current, stored: stored) end end rescue ActiveRecord::NoDatabaseError -- cgit v1.2.3