Skip to content

Commit 157f751

Browse files
Nikita Bulainbulaj
authored andcommitted
Add multiple database (roles) support
1 parent e93e80a commit 157f751

File tree

15 files changed

+438
-18
lines changed

15 files changed

+438
-18
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ gemfiles/*.lock
2020
coverage
2121
*.gem
2222
gemfiles/vendor
23+
vendor/bundle/

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ User-visible changes worth mentioning.
88
## main
99

1010
Add your entry here.
11+
- [#1791] Add support for Rails read replicas with automatic role switching via `enable_multiple_databases` configuration option
1112
- [#1792] Consider expires_in when clear expired tokens with StaleRecordsCleaner.
1213
- [#1790] Fix race condition in refresh token revocation check by moving InvalidGrantReuse check inside the lock block
1314
- [#1788] Fix regex for basic auth to be case-insensitive

lib/doorkeeper.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ module Models
101101
autoload :ResourceOwnerable, "doorkeeper/models/concerns/resource_ownerable"
102102
autoload :Revocable, "doorkeeper/models/concerns/revocable"
103103
autoload :SecretStorable, "doorkeeper/models/concerns/secret_storable"
104+
105+
module Concerns
106+
autoload :WriteToPrimary, "doorkeeper/models/concerns/write_to_primary"
107+
end
104108
end
105109

106110
module Orm

lib/doorkeeper/config.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,22 @@ def reuse_access_token
9999
@config.instance_variable_set(:@reuse_access_token, true)
100100
end
101101

102+
# Enable support for multiple database configurations with read replicas.
103+
# When enabled, wraps database write operations to ensure they use the primary
104+
# (writable) database when automatic role switching is enabled.
105+
#
106+
# For ActiveRecord (Rails 6.1+), this uses `ActiveRecord::Base.connected_to(role: :writing)`.
107+
# Other ORM extensions can implement their own primary database targeting logic.
108+
#
109+
# This prevents `ActiveRecord::ReadOnlyError` when using read replicas with Rails
110+
# automatic role switching. Enable this if your application uses multiple databases
111+
# with automatic role switching for read replicas.
112+
#
113+
# See: https://guides.rubyonrails.org/active_record_multiple_databases.html#activating-automatic-role-switching
114+
def enable_multiple_databases
115+
@config.instance_variable_set(:@enable_multiple_databases, true)
116+
end
117+
102118
# Choose to use the url path for native autorization codes
103119
# Enabling this flag sets the authorization code response route for
104120
# native redirect uris to oauth/authorize/<code>. The default is
@@ -437,6 +453,7 @@ def configure_secrets_for(type, using:, fallback:)
437453
end)
438454

439455
attr_reader :reuse_access_token,
456+
:enable_multiple_databases,
440457
:token_secret_fallback_strategy,
441458
:application_secret_fallback_strategy
442459

lib/doorkeeper/models/access_grant_mixin.rb

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ module AccessGrantMixin
1212
include Models::SecretStorable
1313
include Models::Scopes
1414
include Models::ResourceOwnerable
15+
include Models::Concerns::WriteToPrimary
1516

1617
# Never uses PKCE if PKCE migrations were not generated
1718
def uses_pkce?
@@ -40,12 +41,14 @@ def by_token(token)
4041
# instance of the Resource Owner model or it's ID
4142
#
4243
def revoke_all_for(application_id, resource_owner, clock = Time)
43-
by_resource_owner(resource_owner)
44-
.where(
45-
application_id: application_id,
46-
revoked_at: nil,
47-
)
48-
.update_all(revoked_at: clock.now.utc)
44+
with_primary_role do
45+
by_resource_owner(resource_owner)
46+
.where(
47+
application_id: application_id,
48+
revoked_at: nil,
49+
)
50+
.update_all(revoked_at: clock.now.utc)
51+
end
4952
end
5053

5154
# Implements PKCE code_challenge encoding without base64 padding as described in the spec.

lib/doorkeeper/models/access_token_mixin.rb

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module AccessTokenMixin
1414
include Models::Scopes
1515
include Models::ResourceOwnerable
1616
include Models::ExpirationTimeSqlMath
17+
include Models::Concerns::WriteToPrimary
1718

1819
module ClassMethods
1920
# Returns an instance of the Doorkeeper::AccessToken with
@@ -66,12 +67,14 @@ def by_previous_refresh_token(previous_refresh_token)
6667
# instance of the Resource Owner model or it's ID
6768
#
6869
def revoke_all_for(application_id, resource_owner, clock = Time)
69-
by_resource_owner(resource_owner)
70-
.where(
71-
application_id: application_id,
72-
revoked_at: nil,
73-
)
74-
.update_all(revoked_at: clock.now.utc)
70+
with_primary_role do
71+
by_resource_owner(resource_owner)
72+
.where(
73+
application_id: application_id,
74+
revoked_at: nil,
75+
)
76+
.update_all(revoked_at: clock.now.utc)
77+
end
7578
end
7679

7780
# Looking for not revoked Access Token with a matching set of scopes
@@ -260,7 +263,9 @@ def create_for(application:, resource_owner:, scopes:, **token_attributes)
260263
token_attributes[:resource_owner_id] = resource_owner_id_for(resource_owner)
261264
end
262265

263-
create!(token_attributes)
266+
with_primary_role do
267+
create!(token_attributes)
268+
end
264269
end
265270

266271
# Looking for not revoked Access Token records that belongs to specific
@@ -435,7 +440,12 @@ def revoke_previous_refresh_token!
435440
return if !self.class.refresh_token_revoked_on_use? || previous_refresh_token.blank?
436441

437442
old_refresh_token&.revoke
438-
update_attribute(:previous_refresh_token, "")
443+
444+
if self.class.respond_to?(:with_primary_role)
445+
self.class.with_primary_role { update_attribute(:previous_refresh_token, "") }
446+
else
447+
update_attribute(:previous_refresh_token, "")
448+
end
439449
end
440450

441451
private

lib/doorkeeper/models/concerns/revocable.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ module Revocable
1010
#
1111
def revoke(clock = Time)
1212
return if revoked?
13-
update_attribute(:revoked_at, clock.now.utc)
13+
14+
# Wrap in with_primary_role if the model class supports it
15+
if self.class.respond_to?(:with_primary_role)
16+
self.class.with_primary_role { update_attribute(:revoked_at, clock.now.utc) }
17+
else
18+
update_attribute(:revoked_at, clock.now.utc)
19+
end
1420
end
1521

1622
# Indicates whether the object has been revoked.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
module Doorkeeper
4+
module Models
5+
module Concerns
6+
# Provides support for Rails read replicas by ensuring write operations
7+
# use the primary database when automatic role switching is enabled.
8+
#
9+
# When Rails uses automatic role switching with read replicas, GET requests
10+
# are routed to read-only databases. However, Doorkeeper may need to write
11+
# to the database during GET requests (e.g., creating access tokens during
12+
# implicit grant flow). This concern wraps write operations with
13+
# `connected_to(role: :writing)` to ensure they use the primary database.
14+
#
15+
# This concern is only active when:
16+
# 1. ActiveRecord supports `connected_to` (Rails 6.1+)
17+
# 2. The configuration option is enabled
18+
#
19+
module WriteToPrimary
20+
extend ActiveSupport::Concern
21+
22+
class_methods do
23+
# Executes the given block with a connection to the primary database
24+
# for writing, if read replica support is enabled and available.
25+
#
26+
# @yield Block to execute with write connection
27+
# @return The result of the block
28+
#
29+
def with_primary_role(&block)
30+
if should_use_primary_role?
31+
::ActiveRecord::Base.connected_to(role: :writing, &block)
32+
else
33+
yield
34+
end
35+
end
36+
37+
private
38+
39+
# Determines if we should explicitly use the primary role for writes
40+
#
41+
# @return [Boolean]
42+
#
43+
def should_use_primary_role?
44+
# Guard clause: return false if ActiveRecord is not available
45+
return false unless defined?(::ActiveRecord::Base)
46+
47+
# Only use primary role if:
48+
# 1. The enable_multiple_databases option is enabled in config
49+
# 2. ActiveRecord supports connected_to (Rails 6.1+)
50+
Doorkeeper.config.enable_multiple_databases &&
51+
::ActiveRecord::Base.respond_to?(:connected_to)
52+
end
53+
end
54+
end
55+
end
56+
end
57+
end

lib/doorkeeper/oauth/authorization/code.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ def initialize(pre_auth, resource_owner)
1414
def issue_token!
1515
return @token if defined?(@token)
1616

17-
@token = Doorkeeper.config.access_grant_model.create!(access_grant_attributes)
17+
@token = Doorkeeper.config.access_grant_model.with_primary_role do
18+
Doorkeeper.config.access_grant_model.create!(access_grant_attributes)
19+
end
1820
end
1921

2022
def oob_redirect

lib/generators/doorkeeper/templates/initializer.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@
55
# Check the list of supported ORMs here: https://github.com/doorkeeper-gem/doorkeeper#orms
66
orm :active_record
77

8+
# Enable support for multiple database configurations with read replicas.
9+
# When enabled, Doorkeeper will wrap database write operations to ensure they
10+
# use the primary (writable) database when automatic role switching is enabled.
11+
#
12+
# For ActiveRecord (Rails 6.1+), this uses `ActiveRecord::Base.connected_to(role: :writing)`.
13+
# Other ORM extensions can implement their own primary database targeting logic.
14+
#
15+
# enable_multiple_databases
16+
#
17+
# This prevents `ActiveRecord::ReadOnlyError` when using read replicas with Rails
18+
# automatic role switching. Enable this if your application uses multiple databases
19+
# with automatic role switching for read replicas.
20+
#
21+
# See: https://guides.rubyonrails.org/active_record_multiple_databases.html#activating-automatic-role-switching
22+
823
# This block will be called to check whether the resource owner is authenticated or not.
924
resource_owner_authenticator do
1025
raise "Please configure doorkeeper resource_owner_authenticator block located in #{__FILE__}"

0 commit comments

Comments
 (0)