Environment-based destructive action protection / API surface hardening
Description
This commit centralizes the protected_environments API by moving it from ActiveRecord::Base to ActiveRecord, defaulting to production, and introducing deprecation warnings for older usage. This hardens protection against destructive migrations by ensuring a single, canonical source of truth for environment-based restrictions and guiding developers to migrate usage. It reduces the risk of accidentally allowing destructive actions in non-production environments due to API scoping mistakes or legacy code paths. The tests are updated to reflect the new API usage and to signal deprecation for the old path.
Commit Details
Author: Hartley McGuire
Date: 2026-02-24 13:58 UTC
Message:
Merge pull request #56749 from skipkayhil/hm-nymvutosvmtpqvny
Move protected_environments to ActiveRecord module
Triage Assessment
Vulnerability Type: Destructive action protection (environment-based) / Privilege and safety hardening
Confidence: MEDIUM
Reasoning:
The commit centralizes and enforces protection of destructive actions via protected_environments, defaulting to production. It also refactors how the protected_environments API is accessed (ActiveRecord.protected_environments) and adds deprecation warnings for older usage, indicating a security-related API change to prevent destructive migrations in non-production environments.
Verification Assessment
Vulnerability Type: Environment-based destructive action protection / API surface hardening
Confidence: MEDIUM
Affected Versions: 8.1.0 - 8.1.2
Code Diff
diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb
index 4d118254e2117..bc8f7d064cbc5 100644
--- a/activerecord/lib/active_record.rb
+++ b/activerecord/lib/active_record.rb
@@ -191,6 +191,19 @@ module Tasks
singleton_class.attr_accessor :lazily_load_schema_cache
self.lazily_load_schema_cache = false
+ ##
+ # :singlton-method: protected_environments
+ # The array of names of environments where destructive actions should be
+ # prohibited. By default, the value is <tt>["production"]</tt>.
+ singleton_class.attr_reader :protected_environments
+
+ # Sets an array of names of environments where destructive actions should be
+ # prohibited.
+ def self.protected_environments=(environments)
+ @protected_environments = environments.map(&:to_s)
+ end
+ self.protected_environments = ["production"]
+
##
# :singleton-method: schema_cache_ignored_tables
# A list of tables or regex's to match tables to ignore when
diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb
index 2affd00d609ac..fc0214df77127 100644
--- a/activerecord/lib/active_record/migration.rb
+++ b/activerecord/lib/active_record/migration.rb
@@ -1369,7 +1369,7 @@ def current_environment # :nodoc:
end
def protected_environment? # :nodoc:
- ActiveRecord::Base.protected_environments.include?(last_stored_environment) if last_stored_environment
+ ActiveRecord.protected_environments.include?(last_stored_environment) if last_stored_environment
end
def last_stored_environment # :nodoc:
diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb
index 41ec5b66306de..41d29f01a10a2 100644
--- a/activerecord/lib/active_record/model_schema.rb
+++ b/activerecord/lib/active_record/model_schema.rb
@@ -178,8 +178,6 @@ module ModelSchema
alias_method :inheritance_column=, :real_inheritance_column=
end
- self.protected_environments = ["production"]
-
self.ignored_columns = [].freeze
self.only_columns = [].freeze
@@ -313,7 +311,14 @@ def full_table_name_suffix # :nodoc:
# The array of names of environments where destructive actions should be prohibited. By default,
# the value is <tt>["production"]</tt>.
def protected_environments
- if defined?(@protected_environments)
+ ActiveRecord.deprecator.warn <<~MSG
+ ActiveRecord::Base.protected_environments is deprecated in favor of
+ ActiveRecord.protected_environments and will be removed in Rails 9.0.
+ MSG
+
+ if self == ActiveRecord::Base
+ ActiveRecord.protected_environments
+ elsif defined?(@protected_environments)
@protected_environments
else
superclass.protected_environments
@@ -322,7 +327,16 @@ def protected_environments
# Sets an array of names of environments where destructive actions should be prohibited.
def protected_environments=(environments)
- @protected_environments = environments.map(&:to_s)
+ ActiveRecord.deprecator.warn <<~MSG
+ ActiveRecord::Base.protected_environments= is deprecated in favor of
+ ActiveRecord.protected_environments= and will be removed in Rails 9.0.
+ MSG
+
+ if self == ActiveRecord::Base
+ ActiveRecord.protected_environments = environments
+ else
+ @protected_environments = environments.map(&:to_s)
+ end
end
def real_inheritance_column=(value) # :nodoc:
diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb
index e003c1950b730..33f2b0f04e665 100644
--- a/activerecord/test/cases/base_test.rb
+++ b/activerecord/test/cases/base_test.rb
@@ -1923,15 +1923,53 @@ def test_default_values_are_deeply_dupped
end
test "protected environments by default is an array with production" do
- assert_equal ["production"], ActiveRecord::Base.protected_environments
+ assert_equal ["production"], ActiveRecord.protected_environments
+
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal ["production"], ActiveRecord::Base.protected_environments
+ end
end
def test_protected_environments_are_stored_as_an_array_of_string
- previous_protected_environments = ActiveRecord::Base.protected_environments
- ActiveRecord::Base.protected_environments = [:staging, "production"]
- assert_equal ["staging", "production"], ActiveRecord::Base.protected_environments
+ previous_protected_environments = ActiveRecord.protected_environments
+
+ ActiveRecord.protected_environments = [:staging, "production"]
+
+ assert_equal ["staging", "production"], ActiveRecord.protected_environments
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal ["staging", "production"], ActiveRecord::Base.protected_environments
+ end
+
+ assert_deprecated(ActiveRecord.deprecator) do
+ ActiveRecord::Base.protected_environments = [:prod, :staging]
+ end
+
+ assert_equal ["prod", "staging"], ActiveRecord.protected_environments
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal ["prod", "staging"], ActiveRecord::Base.protected_environments
+ end
ensure
- ActiveRecord::Base.protected_environments = previous_protected_environments
+ ActiveRecord.protected_environments = previous_protected_environments
+ end
+
+ def test_deprecated_protected_environments_on_subclasses
+ model = Class.new(ActiveRecord::Base)
+
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal ["production"], model.protected_environments
+ end
+
+ assert_deprecated(ActiveRecord.deprecator) do
+ model.protected_environments = [:prod]
+ end
+
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal ["prod"], model.protected_environments
+ end
+
+ assert_deprecated(ActiveRecord.deprecator) do
+ assert_equal ["production"], ActiveRecord::Base.protected_environments
+ end
end
test "#present? and #blank? on ActiveRecord::Base classes" do
diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb
index 12c095037fc97..e0aa2ae1849fc 100644
--- a/activerecord/test/cases/tasks/database_tasks_test.rb
+++ b/activerecord/test/cases/tasks/database_tasks_test.rb
@@ -95,7 +95,7 @@ def teardown
end
def test_raises_an_error_when_called_with_protected_environment
- protected_environments = ActiveRecord::Base.protected_environments
+ protected_environments = ActiveRecord.protected_environments
current_env = ActiveRecord::Base.connection_pool.migration_context.current_environment
ActiveRecord::Base.connection_pool.internal_metadata[:environment] = current_env
@@ -110,18 +110,18 @@ def test_raises_an_error_when_called_with_protected_environment
# Assert no error
ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!("arunit")
- ActiveRecord::Base.protected_environments = [current_env]
+ ActiveRecord.protected_environments = [current_env]
assert_raise(ActiveRecord::ProtectedEnvironmentError) do
ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!("arunit")
end
end
ensure
- ActiveRecord::Base.protected_environments = protected_environments
+ ActiveRecord.protected_environments = protected_environments
end
def test_raises_an_error_when_called_with_protected_environment_which_name_is_a_symbol
- protected_environments = ActiveRecord::Base.protected_environments
+ protected_environments = ActiveRecord.protected_environments
current_env = ActiveRecord::Base.connection_pool.migration_context.current_environment
ActiveRecord::Base.connection_pool.internal_metadata[:environment] = current_env
@@ -136,13 +136,13 @@ def test_raises_an_error_when_called_with_protected_environment_which_name_is_a_
# Assert no error
ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!("arunit")
- ActiveRecord::Base.protected_environments = [current_env.to_sym]
+ ActiveRecord.protected_environments = [current_env.to_sym]
assert_raise(ActiveRecord::ProtectedEnvironmentError) do
ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!("arunit")
end
end
ensure
- ActiveRecord::Base.protected_environments = protected_environments
+ ActiveRecord.protected_environments = protected_environments
end
def test_raises_an_error_if_no_migrations_have_been_made
@@ -194,7 +194,7 @@ def test_with_multiple_databases
env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call
with_multi_db_configurations(env) do
- protected_environments = ActiveRecord::Base.protected_environments
+ protected_environments = ActiveRecord.protected_environments
current_env = ActiveRecord::Base.connection_pool.migration_context.current_environment
assert_equal current_env, env
@@ -214,13 +214,13 @@ def test_with_multiple_databases
schema_migration.create_table
schema_migration.create_version("1")
- ActiveRecord::Base.protected_environments = [current_env.to_sym]
+ ActiveRecord.protected_environments = [current_env.to_sym]
assert_raise(ActiveRecord::ProtectedEnvironmentError) do
ActiveRecord::Tasks::DatabaseTasks.check_protected_environments!(env)
end
ensure
- ActiveRecord::Base.protected_environments = protected_environments
+ ActiveRecord.protected_environments = protected_environments
end
end