Skip to content

Guard tenant context at database access, not at object creation - #331

Merged
flavorjones merged 11 commits into
mainfrom
marshal-tenanted-records
Aug 4, 2026
Merged

Guard tenant context at database access, not at object creation#331
flavorjones merged 11 commits into
mainfrom
marshal-tenanted-records

Conversation

@flavorjones

Copy link
Copy Markdown
Member

This fixes the hang in the parallel test suite, reported in #243.

The 7.1 marshalling format dumps loaded association targets. marshal_load restores them, and it calls association(name) on the record. The tenant context check hung off that method. So the untenanted parent process raised NoTenantError before 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 Association object, 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. Job and GlobalId use 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, and MessagePack payloads carry the tenant. A record keeps its tenant across a dump and load in any context.
  • ActiveRecord::Tenanted::Associations checks at the association readers and at Association#scope. It resolves klass first, 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.
  • #destroy checks in a prepended before_destroy callback.
  • A prepended before_save callback rejects a belongs_to target from another tenant. This is the one case where the foreign key lands on the record you save, not on the target.
  • ==, eql?, and hash use the tenant. Two rows with the same class and id in different databases are no longer equal.
  • UntenantedConnectionPool implements clear_query_cache. Persistence#reload calls it before it asks for a connection.

These checks are guardrails against developer mistakes. They are not a trust boundary.

[Fix #243]

Copilot AI balanced review requested due to automatic review settings August 4, 2026 15:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 nil tenant is currently ignored here. Because from_json is 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": null restores nil.
        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 by super. Track whether the key existed and assign its value even when it is nil; 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.

Comment thread lib/active_record/tenanted/tenant.rb Outdated
Comment thread Gemfile
Comment thread lib/active_record/tenanted/message_pack.rb
Copilot AI review requested due to automatic review settings August 4, 2026 15:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_load has run init_with_attributes and all find/initialize callbacks. When a foo payload is loaded under bar (or with no tenant), callbacks see the ambient tenant rather than foo, so callback-derived state can be permanently incorrect. Restore the extracted tenant at the pre-callback initialization point instead of after super.
      def marshal_load(state)
        tenant_name = state[0].delete("tenant")

        super

        @tenant = tenant_name if tenant_name

lib/active_record/tenanted/tenant.rb:131

  • super invokes init_with_attributes, including find/initialize callbacks, before @tenant is restored below. Loading a foo snapshot while bar is current therefore makes callbacks observe tenant bar; any callback-derived state remains wrong after the final tenant is corrected. Set the serialized tenant through the block that init_with runs 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

  • super builds the record with init_with_attributes, which runs find/initialize callbacks, and only then does this tap restore @tenant. Thus callbacks see the ambient tenant instead of the tenant encoded in the MessagePack payload, and state they derive from tenant remains 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

Comment thread lib/active_record/tenanted/associations.rb
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.
Copilot AI review requested due to automatic review settings August 4, 2026 17:11
@flavorjones
flavorjones force-pushed the marshal-tenanted-records branch from af22c27 to a7943fa Compare August 4, 2026 17:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 runs after_find and after_initialize inside super. Loading a foo record under bar therefore exposes bar to those callbacks, and a callback can query bar before @tenant is corrected. Set the keyed tenant before callback execution and make init_internals preserve a preloaded value, including explicit nil.
      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

  • validate still bypasses this guard. Active Record defines it with alias_method :validate, :valid?, so that alias remains bound to Active Record's original implementation after this module overrides valid?. A carried record can therefore run a uniqueness validation against the wrong tenant through record.validate; re-alias it after this override.
      def valid?(...)
        ensure_tenant_context_safety

        super

lib/active_record/tenanted/message_pack.rb:18

  • super fully constructs the record and runs its initialization callbacks before this tap assigns the serialized tenant. Thus a foo record decoded under bar presents itself as bar during callbacks, allowing tenant-sensitive callback work or association queries to use the wrong database. Build the record with the tenant installed before init_with_attributes runs, 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

  • super runs after_find and after_initialize before this assignment. When a foo YAML payload is loaded under bar, those callbacks observe tenant == "bar"; an association query in a callback can therefore pass the new guard and read bar before the record is corrected to foo. Extract the keyed tenant before super and make init_internals preserve a preloaded value (including explicit nil), 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")

Comment on lines +21 to +23
def ==(other)
super && tenant == other.tenant
end
@flavorjones
flavorjones merged commit 8b4ad24 into main Aug 4, 2026
12 checks passed
@flavorjones
flavorjones deleted the marshal-tenanted-records branch August 4, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parallel Test Hang: NoTenantError During Error Marshalling

2 participants