Guard tenant context at database access, not at object creation - #331
Conversation
There was a problem hiding this comment.
Pull request overview
Moves tenant safety checks to database-access boundaries and preserves tenant identity during serialization, addressing parallel test hangs from issue #243.
Changes:
- Guards association and persistence database operations.
- Serializes tenant identity across supported formats.
- Adds regression tests and MessagePack support.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
.rubocop.yml |
Allows Marshal loading in serialization tests. |
Gemfile |
Adds MessagePack test dependency. |
Gemfile.lock |
Locks MessagePack dependency. |
lib/active_record/tenanted/associations.rb |
Guards association database access. |
lib/active_record/tenanted/message_pack.rb |
Restores serialized tenant identity. |
lib/active_record/tenanted/railtie.rb |
Installs association and MessagePack patches. |
lib/active_record/tenanted/tenant.rb |
Adds persistence guards, serialization, and tenant-aware equality. |
lib/active_record/tenanted/untenanted_connection_pool.rb |
Supports query-cache clearing without a connection. |
test/unit/serialization_test.rb |
Tests serialization formats and associations. |
test/unit/tenant_test.rb |
Tests tenant guards and equality behavior. |
test/unit/untenanted_connection_pool_test.rb |
Tests query-cache clearing. |
Suppressed comments (2)
lib/active_record/tenanted/tenant.rb:138
- An explicitly serialized
niltenant is currently ignored here. Becausefrom_jsonis invoked on an instance initialized in the loading context, a record created and dumped while untenanted becomes bound to whichever tenant loads it, bypassing the record-at-creation tenant identity. Distinguish key presence from its value so missing legacy keys retain current behavior while"tenant": nullrestoresnil.
tenant_name = hash.delete("tenant")
@tenant = tenant_name if tenant_name
lib/active_record/tenanted/tenant.rb:149
- The payload always includes the tenant key, including when its value is
nil, but this truthiness check does not restore that state. Loading an untenanted new record under another tenant therefore rebinds it to the loading context established bysuper. Track whether the key existed and assign its value even when it isnil; only legacy payloads without the key should retain the initialized context.
tenant_name = state[0].delete("tenant")
super
@tenant = tenant_name if tenant_name
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
lib/active_record/tenanted/tenant.rb:149
- The serialized tenant is assigned only after Rails'
marshal_loadhas runinit_with_attributesand all find/initialize callbacks. When afoopayload is loaded underbar(or with no tenant), callbacks see the ambient tenant rather thanfoo, so callback-derived state can be permanently incorrect. Restore the extracted tenant at the pre-callback initialization point instead of aftersuper.
def marshal_load(state)
tenant_name = state[0].delete("tenant")
super
@tenant = tenant_name if tenant_name
lib/active_record/tenanted/tenant.rb:131
superinvokesinit_with_attributes, including find/initialize callbacks, before@tenantis restored below. Loading afoosnapshot whilebaris current therefore makes callbacks observe tenantbar; any callback-derived state remains wrong after the final tenant is corrected. Set the serialized tenant through the block thatinit_withruns after internals initialization but before callbacks, while preserving the caller's block.
This issue also appears on line 144 of the same file.
def init_with(coder, &block)
super
tenant_name = coder["tenant"]
@tenant = tenant_name if tenant_name
lib/active_record/tenanted/message_pack.rb:17
superbuilds the record withinit_with_attributes, which runs find/initialize callbacks, and only then does thistaprestore@tenant. Thus callbacks see the ambient tenant instead of the tenant encoded in the MessagePack payload, and state they derive fromtenantremains wrong. The decoder needs to inject the extracted tenant before those callbacks run.
super.tap do |record|
record.instance_variable_set(:@tenant, tenant_name) if tenant_name
end
The tenant is serialized as an ordinary "tenant" attribute in each format's payload (the same pattern as Job and GlobalId), but remains a separate instance variable on the record, not a declared attribute. Note that promoting it to a real attribute would break record.attributes consumers, e.g. ActiveStorage::FixtureSet fixture generation. ref: #243
Tenant context was checked when an Association object was created, not when it read from the database. Rails serializers create associations only to dump and restore their targets, so a record holding a loaded association could not round-trip through Marshal or MessagePack outside of a tenant context, and a parallel test suite hung when a worker reported an exception carrying such a record. The check also presumed every polymorphic association was tenanted, so reaching an untenanted target raised where the equivalent non-polymorphic association did not. A collection proxy that outlived its tenant context queried the current tenant's database and silently returned another tenant's rows. ActiveRecord::Tenanted::Associations will run the check at the two seams where an association touches the database: the readers, so that the exception is raised at the call site that made the mistake, and Association#scope, through which every query an association builds funnels. Both resolve klass first, so a polymorphic association is checked against the class it actually points at rather than presumed tenanted. TenantCommon#ensure_tenant_context_safety becomes public so the association can call it on its owner. ref: #243
UntenantedConnectionPool will implement `clear_query_cache` as a no-op.
The `before_save` callback also gains `prepend: true`.
- `#update_column` - `#update_columns` - `#touch` - `#increment!` - `#decrement!`
The `before_save` guards are now exercised with `validate: false`, since `#save` validates before running its callbacks.
The deserializers assigned the tenant only when the serialized value was truthy, so a record with no tenant took on the tenant of whichever context loaded it. They will test for the key rather than its value. A payload written before the tenant was serialized has no key at all, and still keeps the loading context.
af22c27 to
a7943fa
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
lib/active_record/tenanted/tenant.rb:149
- The serialized tenant is restored only after
super, but Rails' marshal loader runsafter_findandafter_initializeinsidesuper. Loading afoorecord underbartherefore exposesbarto those callbacks, and a callback can querybarbefore@tenantis corrected. Set the keyed tenant before callback execution and makeinit_internalspreserve a preloaded value, including explicitnil.
def marshal_load(state)
has_tenant = state[0].key?("tenant")
tenant_name = state[0].delete("tenant")
super
@tenant = tenant_name if has_tenant
lib/active_record/tenanted/tenant.rb:54
validatestill bypasses this guard. Active Record defines it withalias_method :validate, :valid?, so that alias remains bound to Active Record's original implementation after this module overridesvalid?. A carried record can therefore run a uniqueness validation against the wrong tenant throughrecord.validate; re-alias it after this override.
def valid?(...)
ensure_tenant_context_safety
super
lib/active_record/tenanted/message_pack.rb:18
superfully constructs the record and runs its initialization callbacks before thistapassigns the serialized tenant. Thus afoorecord decoded underbarpresents itself asbarduring callbacks, allowing tenant-sensitive callback work or association queries to use the wrong database. Build the record with the tenant installed beforeinit_with_attributesruns, while preserving the no-key behavior for old payloads.
super.tap do |record|
record.instance_variable_set(:@tenant, tenant_name) if has_tenant
end
lib/active_record/tenanted/tenant.rb:128
superrunsafter_findandafter_initializebefore this assignment. When afooYAML payload is loaded underbar, those callbacks observetenant == "bar"; an association query in a callback can therefore pass the new guard and readbarbefore the record is corrected tofoo. Extract the keyed tenant beforesuperand makeinit_internalspreserve a preloaded value (including explicitnil), while leaving old payloads to inherit the loading context.
This issue also appears on line 143 of the same file.
def init_with(coder, &block)
super
# Psych::Coder does not implement #key?, but exposes the underlying hash as #map
@tenant = coder.map["tenant"] if coder.map.key?("tenant")
| def ==(other) | ||
| super && tenant == other.tenant | ||
| end |
This fixes the hang in the parallel test suite, reported in #243.
The 7.1 marshalling format dumps loaded association targets.
marshal_loadrestores them, and it callsassociation(name)on the record. The tenant context check hung off that method. So the untenanted parent process raisedNoTenantErrorbefore it read the database. The worker died while it reported the result over DRb, and the suite hung.The work splits into two problems. The check ran when Rails created an
Associationobject, not when the association read the database. The record also did not serialize its tenant, so a loaded record lost its tenant identity.This branch fixes both problems. Each serializer payload now carries the tenant as an ordinary
"tenant"attribute.JobandGlobalIduse the same pattern. The check moves to the points where an association reads or writes the database. An audit of the rest of the instance API found more paths into another tenant's database, and this branch guards those too.Marshal,JSON,YAML, andMessagePackpayloads carry the tenant. A record keeps its tenant across a dump and load in any context.ActiveRecord::Tenanted::Associationschecks at the association readers and atAssociation#scope. It resolvesklassfirst, so it checks a polymorphic association against the class the record points at.#reload,#delete,#update_column,#update_columns,#touch,#increment!,#decrement!, and#valid?check before they touch the database.#destroychecks in a prependedbefore_destroycallback.before_savecallback rejects abelongs_totarget from another tenant. This is the one case where the foreign key lands on the record you save, not on the target.==,eql?, andhashuse the tenant. Two rows with the same class and id in different databases are no longer equal.UntenantedConnectionPoolimplementsclear_query_cache.Persistence#reloadcalls it before it asks for a connection.These checks are guardrails against developer mistakes. They are not a trust boundary.
[Fix #243]