Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions activerecord/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
* Add the ability to prevent access to a database for the duration of a block.

Allows the application to prevent database access. This can be useful to
ensure a routine does not depend on database access.

If `while_preventing_access` is called and there is a database query within
the block, the connection will raise an exception.

One purpose of this is to catch accidental reads.

For example, an application may have a method which is known to be called
in tight loops. If database access from within this method could lead to
unacceptable performance impacts, it may be desirable to prevent database
access within this method.

*Stephen Crosby*

* Add dirties option to uncached

This adds a `dirties` option to `ActiveRecord::Base.uncached` and
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/active_record.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
module ActiveRecord
extend ActiveSupport::Autoload

autoload :AccessPrevention
autoload :Base
autoload :Callbacks
autoload :ConnectionHandling
Expand Down
34 changes: 34 additions & 0 deletions activerecord/lib/active_record/access_prevention.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# frozen_string_literal: true

module ActiveRecord
class PreventedAccessError < ActiveRecordError # :nodoc:
end

# = Active Record Access Prevention
module AccessPrevention
extend ActiveSupport::Concern

thread_mattr_accessor :enabled, instance_accessor: false, default: false

module ClassMethods
# Lets you prevent database access from ActiveRecord for the duration of
# a block.
#
# ==== Examples
# ActiveRecord::Base.while_preventing_access do
# Project.first # raises an exception
# end
#
def while_preventing_access(&block)
AccessPrevention.with(enabled: true, &block)
end

# Determines whether access is currently being prevented.
#
# Returns the value of +enabled+.
def preventing_access?
AccessPrevention.enabled
end
end
end
end
1 change: 1 addition & 0 deletions activerecord/lib/active_record/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ class Base
include Suppressor
include Normalization
include Marshalling::Methods
include AccessPrevention

self.param_delimiter = "_"
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: fa
private
def internal_execute(sql, name = "SCHEMA", allow_retry: false, materialize_transactions: true)
sql = transform_query(sql)
check_if_access_prevented(sql)
check_if_write_query(sql)

mark_transaction_written_if_write(sql)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ def check_if_write_query(sql) # :nodoc:
end
end

def check_if_access_prevented(sql) # :nodoc:
if AccessPrevention.enabled
raise ActiveRecord::PreventedAccessError, "Query attempted while preventing access: #{sql}"
end
end

def replica?
@config[:replica] || false
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ def disable_referential_integrity # :nodoc:
# needs to be explicitly freed or not.
def execute_and_free(sql, name = nil, async: false) # :nodoc:
sql = transform_query(sql)
check_if_access_prevented(sql)
check_if_write_query(sql)

mark_transaction_written_if_write(sql)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def raw_execute(sql, name, async: false, allow_retry: false, materialize_transac

def exec_stmt_and_free(sql, name, binds, cache_stmt: false, async: false)
sql = transform_query(sql)
check_if_access_prevented(sql)
check_if_write_query(sql)

mark_transaction_written_if_write(sql)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,7 @@ def load_types_queries(initializer, oids)

def execute_and_clear(sql, name, binds, prepare: false, async: false, allow_retry: false, materialize_transactions: true)
sql = transform_query(sql)
check_if_access_prevented(sql)
check_if_write_query(sql)

if !prepare || without_prepared_statement?(binds)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def explain(arel, binds = [], _options = [])

def internal_exec_query(sql, name = nil, binds = [], prepare: false, async: false) # :nodoc:
sql = transform_query(sql)
check_if_access_prevented(sql)
check_if_write_query(sql)

mark_transaction_written_if_write(sql)
Expand Down Expand Up @@ -136,6 +137,7 @@ def execute_batch(statements, name = nil)
statements = statements.map { |sql| transform_query(sql) }
sql = combine_multi_statements(statements)

check_if_access_prevented(sql)
check_if_write_query(sql)
mark_transaction_written_if_write(sql)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def select_all(*, **) # :nodoc:

def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: false) # :nodoc:
sql = transform_query(sql)
check_if_access_prevented(sql)
check_if_write_query(sql)
mark_transaction_written_if_write(sql)

Expand All @@ -23,6 +24,7 @@ def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: fa

def exec_insert(sql, name, binds, pk = nil, sequence_name = nil, returning: nil) # :nodoc:
sql = transform_query(sql)
check_if_access_prevented(sql)
check_if_write_query(sql)
mark_transaction_written_if_write(sql)

Expand All @@ -32,6 +34,7 @@ def exec_insert(sql, name, binds, pk = nil, sequence_name = nil, returning: nil)

def exec_delete(sql, name = nil, binds = []) # :nodoc:
sql = transform_query(sql)
check_if_access_prevented(sql)
check_if_write_query(sql)
mark_transaction_written_if_write(sql)

Expand Down
4 changes: 4 additions & 0 deletions activerecord/lib/active_record/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ class ExclusiveConnectionTimeoutError < ConnectionTimeoutError
class ReadOnlyError < ActiveRecordError
end

# Raised when database access is attempted on a connection preventing access.
class PreventedAccessError < ActiveRecordError
end

# Raised when Active Record cannot find a record by given id or set of ids.
class RecordNotFound < ActiveRecordError
attr_reader :model, :primary_key, :id
Expand Down
32 changes: 32 additions & 0 deletions activerecord/test/cases/adapter_prevent_access_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

require "cases/helper"
require "support/connection_helper"

module ActiveRecord
class AdapterPreventAccessTest < ActiveRecord::TestCase
def setup
@connection = ActiveRecord::Base.lease_connection
end

def test_preventing_access_predicate
assert_not ActiveRecord::Base.preventing_access?

ActiveRecord::Base.while_preventing_access do
assert_predicate ActiveRecord::Base, :preventing_access?
end

assert_not ActiveRecord::Base.preventing_access?
end

def test_errors_when_query_is_called_while_preventing_access
@connection.select_all("SELECT count(*) FROM subscribers")

ActiveRecord::Base.while_preventing_access do
assert_raises(ActiveRecord::PreventedAccessError) do
@connection.select_all("SELECT count(*) FROM subscribers")
end
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

require "cases/helper"
require "support/ddl_helper"

class AdapterPreventAccessTest < ActiveRecord::AbstractMysqlTestCase
include DdlHelper

def setup
@conn = ActiveRecord::Base.lease_connection
end

def test_error_when_a_query_is_called_while_preventing_access
@conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')")

ActiveRecord::Base.while_preventing_access do
assert_raises(ActiveRecord::PreventedAccessError) do
@conn.execute("SELECT `engines`.* FROM `engines` WHERE `engines`.`car_id` = '138853948594'")
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

require "cases/helper"
require "support/ddl_helper"
require "support/connection_helper"

module ActiveRecord
module ConnectionAdapters
class PostgreSQLAdapterPreventAccessTest < ActiveRecord::PostgreSQLTestCase
include DdlHelper
include ConnectionHelper

def setup
@connection = ActiveRecord::Base.lease_connection
end

def test_error_when_a_query_is_called_while_preventing_access
with_example_table do
@connection.execute("INSERT INTO ex (data) VALUES ('138853948594')")

ActiveRecord::Base.while_preventing_access do
assert_raises(ActiveRecord::PreventedAccessError) do
@connection.execute("SELECT * FROM ex WHERE data = '138853948594'")
end
end
end
end

private
def with_example_table(definition = "id serial primary key, number integer, data character varying(255)", &block)
super(@connection, "ex", definition, &block)
end
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

require "cases/helper"
require "support/ddl_helper"

module ActiveRecord
module ConnectionAdapters
class SQLite3AdapterPreventAccessTest < ActiveRecord::SQLite3TestCase
include DdlHelper

self.use_transactional_tests = false

def setup
@conn = ActiveRecord::Base.lease_connection
end

def test_errors_when_a_query_is_called_while_preventing_access
with_example_table "id int, data string" do
@conn.execute("INSERT INTO ex (data) VALUES ('138853948594')")

ActiveRecord::Base.while_preventing_access do
assert_raises(ActiveRecord::PreventedAccessError) do
@conn.execute("SELECT data from ex WHERE data = '138853948594'")
end
end
end
end

private
def with_example_table(definition = nil, table_name = "ex", &block)
definition ||= <<~SQL
id integer PRIMARY KEY AUTOINCREMENT,
number integer
SQL
super(@conn, table_name, definition, &block)
end
end
end
end
46 changes: 46 additions & 0 deletions activerecord/test/cases/base_prevent_access_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# frozen_string_literal: true

require "cases/helper"
require "models/bird"

class BasePreventAccessTest < ActiveRecord::TestCase
if !in_memory_db?
test "selecting a record raises if preventing access" do
bird = Bird.create! name: "Bluejay"

ActiveRecord::Base.while_preventing_access do
assert_raises ActiveRecord::PreventedAccessError do
assert_equal bird, Bird.where(name: "Bluejay").last
end
end
end

test "preventing access applies only within while_preventing_access blocks" do
Bird.create! name: "Bluejay"

ActiveRecord::Base.while_preventing_access do
conn1_error = assert_raises ActiveRecord::PreventedAccessError do
Bird.where(name: "Bluejay").last
end

assert_match %r/\AQuery attempted while preventing access: SELECT /, conn1_error.message
end

Professor.create!(name: "Professor Bluejay")

ActiveRecord::Base.while_preventing_access do
conn2_error = assert_raises ActiveRecord::PreventedAccessError do
Professor.create!(name: "Professor Magnificent Frigatebird")
end

assert_match %r/\AQuery attempted while preventing access: INSERT /, conn2_error.message
end
end

test "preventing_access?" do
ActiveRecord::Base.while_preventing_access do
assert_predicate ActiveRecord::Base, :preventing_access?, "expected preventing_access? to return true"
end
end
end
end