From d82bab7f343a56e94aea714df349a9e99aa19872 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 3 Aug 2026 11:44:53 -0400 Subject: [PATCH 01/11] Tenant round-trips through Marshal, JSON, YAML, and MessagePack 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: https://github.com/basecamp/activerecord-tenanted/issues/243 --- .rubocop.yml | 3 + Gemfile | 1 + Gemfile.lock | 162 +++++++++++++++++- lib/active_record/tenanted/message_pack.rb | 25 +++ lib/active_record/tenanted/railtie.rb | 7 + lib/active_record/tenanted/tenant.rb | 46 +++++ test/unit/serialization_test.rb | 187 +++++++++++++++++++++ 7 files changed, 429 insertions(+), 2 deletions(-) create mode 100644 lib/active_record/tenanted/message_pack.rb create mode 100644 test/unit/serialization_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index 9639ac52..d8c2f6e5 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -392,6 +392,9 @@ Security/JSONLoad: Security/MarshalLoad: Enabled: true + Exclude: + # Round-tripping records through Marshal is what this file tests. + - 'test/unit/serialization_test.rb' Security/Open: Enabled: true diff --git a/Gemfile b/Gemfile index 09af04f8..dc83eced 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,7 @@ group :development, :test do gem "minitest", "6.0.6" gem "minitest-mock", "5.27.0" gem "minitest-parallel_fork", "2.1.1", require: false + gem "msgpack", "1.8.3" gem "sqlite3", "2.9.4" end diff --git a/Gemfile.lock b/Gemfile.lock index e8e410e4..2dbeec99 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -187,6 +187,7 @@ GEM minitest-mock (5.27.0) minitest-parallel_fork (2.1.1) minitest (>= 5.15.0) + msgpack (1.8.3) net-imap (0.6.6) date net-protocol @@ -197,6 +198,14 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.5) + nokogiri (1.19.4-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.4-arm-linux-musl) + racc (~> 1.4) nokogiri (1.19.4-arm64-darwin) racc (~> 1.4) nokogiri (1.19.4-x86_64-darwin) @@ -315,6 +324,10 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) + sqlite3 (2.9.4-aarch64-linux-gnu) + sqlite3 (2.9.4-aarch64-linux-musl) + sqlite3 (2.9.4-arm-linux-gnu) + sqlite3 (2.9.4-arm-linux-musl) sqlite3 (2.9.4-arm64-darwin) sqlite3 (2.9.4-x86_64-darwin) sqlite3 (2.9.4-x86_64-linux-gnu) @@ -325,6 +338,9 @@ GEM tailwindcss-rails (4.6.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.3.3) + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) + tailwindcss-ruby (4.3.3-aarch64-linux-musl) tailwindcss-ruby (4.3.3-arm64-darwin) tailwindcss-ruby (4.3.3-x86_64-darwin) tailwindcss-ruby (4.3.3-x86_64-linux-gnu) @@ -352,9 +368,12 @@ GEM zeitwerk (2.8.3) PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl arm64-darwin x86_64-darwin - x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -368,6 +387,7 @@ DEPENDENCIES minitest (= 6.0.6) minitest-mock (= 5.27.0) minitest-parallel_fork (= 2.1.1) + msgpack (= 1.8.3) propshaft puma (>= 5.0) rails! @@ -385,5 +405,143 @@ DEPENDENCIES tailwindcss-rails turbo-rails +CHECKSUMS + action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 + actioncable (8.2.0.alpha) + actionmailbox (8.2.0.alpha) + actionmailer (8.2.0.alpha) + actionpack (8.2.0.alpha) + actiontext (8.2.0.alpha) + actionview (8.2.0.alpha) + activejob (8.2.0.alpha) + activemodel (8.2.0.alpha) + activerecord (8.2.0.alpha) + activerecord-tenanted (0.7.0) + activestorage (8.2.0.alpha) + activesupport (8.2.0.alpha) + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + appraisal (2.5.0) sha256=36989221be127913b0dba8d114da2001e6b2dceea7bd4951200eaba764eed3ce + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + bundler (4.0.13) sha256=19f08be7f27022cf0b89f27da0b044ae075e8270a9ef44ad248a932614e1ca3b + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 + erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 + et-orbi (1.4.1) sha256=007e2685b1d873415a7587ef889336c6dcdf8a47fc9c9a63547582b21660a11b + fugit (1.13.0) sha256=a4f093fce740da52f216740a5041e2a594ea763cdb89e8b2754ca4399634ab18 + globalid (1.4.0) sha256=037f12fbf1d9d7a014d501c2d5c77356fd4ddd96d7a7991d6700bba96706f427 + i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 + importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 + jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a + language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 + lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 + marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 + minitest-mock (5.27.0) sha256=7040ed7185417a966920987eaa6eaf1be4ea1fc5b25bb03ff4703f98564a55b0 + minitest-parallel_fork (2.1.1) sha256=c2ce22a65f7b5a35528f98ef15e388e7cf1630d0107bca0c7112cb5e441efb9e + msgpack (1.8.3) sha256=8bda4a6428d3244e50d6bd55854d354edbada88a4e1f4f5731a39a0f86bee6a1 + net-imap (0.6.6) sha256=96aa4ee50df3060203e649efc341f53480b791d49e150f2fdebf68beb141a8df + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f + nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af + nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca + nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb + nokogiri (1.19.4-arm64-darwin) sha256=a46db9853286e6597b36ebc6953817d15acf3a299583eb3f89fdc6f91dd63527 + nokogiri (1.19.4-x86_64-darwin) sha256=7fd17057d3e1f00e9954a74b3cd76595d3d4a5ef233b7ed9599047c204f70551 + nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a + nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e + psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 + public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 + puma (8.0.2) sha256=c8ed871dfbbe66448ea9ffd46692342d9804d4071522b52b5331b7b6e7b686fb + raabro (1.5.0) sha256=3f998a7bc84f9c84df3ab580634d2e0a5bda4f0841168d56035f529c9877440a + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 + rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + ractor-dispatch (0.2.0) sha256=5fe3eb85c202ac33910b0ada80a15f7a2e9504a057cf91d90ce80a73608492b2 + rails (8.2.0.alpha) + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + railties (8.2.0.alpha) + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.1.2) sha256=050eb1d8b508f1233bed929c0f2c7052302f7adf295230d9cb314e9024078f48 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rubocop (1.89.0) sha256=4dee8e3ee9c45e474834efd9e8d6fd031e8331c8dacdff0de4ad65ae0a6faae7 + rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db + rubocop-minitest (0.39.1) sha256=998398d6da4026d297f0f9bf709a1eac5f2b6947c24431f94af08138510cf7ed + rubocop-packaging (0.6.0) sha256=fb92bd0fb48e6f8cdb1648d2249b0cd51c2497dcc87340132d22f01edbf558a7 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.35.3) sha256=6edd45410866912b9b2e90ae3aeafd31d576df2bb2a9c9408f1667a50c32c7de + rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + rubyzip (3.4.1) sha256=0a79e745b5c25872ebd148457df5665da8530ed8626c993b01457128f173ca02 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.46.0) sha256=cb6cfa6f79eac041749df0b14cdcb0e9fe5df3715a64c77bfaa56b345b99f957 + solid_cable (4.0.2) sha256=084636a67679ad00d23088b33c84047e614bcf41ee559db24b414d83cdc42d03 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.6.0) sha256=b5fc3bb34162e09d8f960df6400d0f9304dc80fe66c2cfface31c32b228de6a1 + sqlite3 (2.9.4-aarch64-linux-gnu) sha256=ecabed721e6eaad54601d2685f09029d90025efc8d931040dc89cb3f8a2080ec + sqlite3 (2.9.4-aarch64-linux-musl) sha256=ffb4255947fb54c8c3eeca97460c9702b40de91ce390455ef7367ca6a3929a31 + sqlite3 (2.9.4-arm-linux-gnu) sha256=9ee2008b9fbec984c3c165b0d7eedd2bd2a415100b761bfa3a4c6fbec9208bf6 + sqlite3 (2.9.4-arm-linux-musl) sha256=8dc1fe4da6977992cd62decf4a93ccf6cc2e124a5e6a340160d52092f70e837a + sqlite3 (2.9.4-arm64-darwin) sha256=1d5aad413a815d236e96d43f05a1acc600b6cd086800770342a3f9c2877499ff + sqlite3 (2.9.4-x86_64-darwin) sha256=f280c476e360b73e86165a5e59b72801385b4a6c3a47f8af5ecefb9d90bec17f + sqlite3 (2.9.4-x86_64-linux-gnu) sha256=537a3eda71b1df1336d0055cbebe55a7317c34870c192c7b6b9d8d0be6871847 + sqlite3 (2.9.4-x86_64-linux-musl) sha256=3fc5e865b4be9a85d998203ef8d0c0fdcb92f20acf34a254346ff8a19088efec + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tailwindcss-rails (4.6.0) sha256=d99512867173d55c5ef8890427682299d8539f550cec1408b3d8667a538bd365 + tailwindcss-ruby (4.3.3) sha256=ee0a64030749862deb501acab4c4aaf5adbee13865746a33299d46d7b5d0952a + tailwindcss-ruby (4.3.3-aarch64-linux-gnu) sha256=c86d6dd3eccc85fe0d792a832b06f2bf3c0a7a83b399308aeb9d8f5725f42a6a + tailwindcss-ruby (4.3.3-aarch64-linux-musl) sha256=72b77ca9edea82383dd09510ab520a122e3cb9f9864ca5b38e27698098d2b899 + tailwindcss-ruby (4.3.3-arm64-darwin) sha256=776c51fc734aac64bde0c253eadd2ed2e610b2ba0d8e760501fe7cbec55fd6cf + tailwindcss-ruby (4.3.3-x86_64-darwin) sha256=483011e5d3792cf5ef4afdfb04c1d95daccf42c30cdb6fe53b77520954e7b922 + tailwindcss-ruby (4.3.3-x86_64-linux-gnu) sha256=2337017ff8b02698480eae1e9637cf01faa0e4824db89d067a13c5a5ee38c9b2 + tailwindcss-ruby (4.3.3-x86_64-linux-musl) sha256=27d478c417bcf73828e5b544744c5bdfd5b5cb54f1a266fc4f185281c32efe6c + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + websocket-driver (0.8.2) sha256=97c556b019bf3410b4961002ac501621e9322d3f8a7bc02161a09301cc4c4146 + websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241 + xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e + zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5 + BUNDLED WITH - 2.7.2 + 4.0.13 diff --git a/lib/active_record/tenanted/message_pack.rb b/lib/active_record/tenanted/message_pack.rb new file mode 100644 index 00000000..ea926afc --- /dev/null +++ b/lib/active_record/tenanted/message_pack.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + module MessagePack # :nodoc: + # This patch sees every record in a payload, unlike the Marshal, YAML, and JSON hooks, which + # are methods on the record and so only ever run for tenanted models. + module Decoder + def build_record(entry) + class_name, attributes_hash, * = entry + + if ActiveSupport::MessagePack::Extensions.load_class(class_name).tenanted? + tenant_name = attributes_hash.delete("tenant") + + super.tap do |record| + record.instance_variable_set(:@tenant, tenant_name) if tenant_name + end + else + super + end + end + end + end + end +end diff --git a/lib/active_record/tenanted/railtie.rb b/lib/active_record/tenanted/railtie.rb index ca9aae3a..b25e07ff 100644 --- a/lib/active_record/tenanted/railtie.rb +++ b/lib/active_record/tenanted/railtie.rb @@ -93,6 +93,13 @@ class Railtie < ::Rails::Railtie config.active_record.check_schema_cache_dump_version = false end + initializer "active_record_tenanted.message_pack" do + ActiveSupport.on_load(:message_pack) do + require "active_record/message_pack" + ActiveRecord::MessagePack::Decoder.prepend ActiveRecord::Tenanted::MessagePack::Decoder + end + end + initializer "active_record_tenanted.monkey_patches" do ActiveSupport.on_load(:active_record) do ActiveRecord::Tenanted::Patches::Attributes.apply_patch diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index dc91b979..61ac244c 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -38,6 +38,46 @@ def association(name) end end + # The tenant is an ordinary attribute in serialized snapshots of the record, the same way Job + # and GlobalId serialize the tenant. Rails's only callers of this method are the serializers + # (Marshal 7.1 format and MessagePack); it does not feed the persistence write path. + def attributes_for_database + super.merge!("tenant" => tenant) + end + + def encode_with(coder) + super + coder["tenant"] = tenant + end + + def init_with(coder, &block) + super + + tenant_name = coder["tenant"] + @tenant = tenant_name if tenant_name + + self + end + + def from_json(json, include_root = include_root_in_json) + hash = ActiveSupport::JSON.decode(json) + hash = hash.values.first if include_root + + tenant_name = hash.delete("tenant") + @tenant = tenant_name if tenant_name + + self.attributes = hash + self + end + + def marshal_load(state) + tenant_name = state[0].delete("tenant") + + super + + @tenant = tenant_name if tenant_name + end + alias to_gid to_global_id alias to_sgid to_signed_global_id @@ -62,6 +102,12 @@ def ensure_tenant_context_safety "but current tenant is #{current_tenant.inspect}" end end + + # Presenting the tenant as one more serializable attribute lets ActiveModel apply the + # `only:` and `except:` options to it, and read it back through the `tenant` reader. + def attribute_names_for_serialization + super + [ "tenant" ] + end end module Tenant diff --git a/test/unit/serialization_test.rb b/test/unit/serialization_test.rb new file mode 100644 index 00000000..a71d28e5 --- /dev/null +++ b/test/unit/serialization_test.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_support/message_pack" + +SERIALIZERS = { + "Marshal" => { + dump: ->(record) { Marshal.dump(record) }, + load: ->(payload) { Marshal.load(payload) }, + }, + "JSON" => { + dump: ->(record) { record.to_json }, + load: ->(payload) { User.new.from_json(payload) }, + }, + "YAML" => { + dump: ->(record) { YAML.dump(record) }, + load: ->(payload) { YAML.unsafe_load(payload) }, + }, + "MessagePack" => { + dump: ->(record) { ActiveSupport::MessagePack::CacheSerializer.dump(record) }, + load: ->(payload) { ActiveSupport::MessagePack::CacheSerializer.load(payload) }, + }, +} + +describe "Serializing tenanted records" do + with_scenario(:primary_db, :primary_record) do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") + end + + SERIALIZERS.each do |format, serializer| + describe format do + let(:dump_in_tenant_context) do + TenantedApplicationRecord.with_tenant("foo") { serializer[:dump].call(User.first) } + end + + let(:dump_outside_tenant_context) do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + TenantedApplicationRecord.without_tenant { serializer[:dump].call(user) } + end + + describe "dumping in a tenanted context" do + test "loading in the same tenant context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("foo") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + + test "loading outside of a tenanted context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + + test "loading in another tenant context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("bar") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + end + + describe "dumping outside of a tenanted context" do + test "loading in the same tenant context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("foo") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + + test "loading outside of a tenanted context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + + test "loading in another tenant context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("bar") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_equal("user1@foo.example.org", loaded.email) + end + end + end + end + end +end + +describe "Serialization options on a tenanted record" do + with_scenario(:primary_db, :primary_record) do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + end + + let(:user) { TenantedApplicationRecord.with_tenant("foo") { User.first } } + + test "the tenant is serialized by default" do + assert_equal("foo", user.serializable_hash["tenant"]) + end + + test "only: excludes the tenant when it is not named" do + assert_not_includes(user.serializable_hash(only: [ :email ]).keys, "tenant") + end + + test "only: includes the tenant when it is named" do + assert_equal("foo", user.serializable_hash(only: [ :email, :tenant ])["tenant"]) + end + + test "except: excludes the tenant when it is named" do + assert_not_includes(user.serializable_hash(except: [ :tenant ]).keys, "tenant") + end + + test "except: includes the tenant when another attribute is named" do + assert_equal("foo", user.serializable_hash(except: [ :email ])["tenant"]) + end + end +end + +# The Marshal, YAML, and JSON hooks are all defined on TenantCommon and so never run for an +# untenanted model. The MessagePack decoder patch is the only one installed globally, on every +# record in a payload, whatever its class. +describe "Serializing an untenanted record that has a tenant attribute" do + with_scenario(:primary_db, :primary_record) do + setup do + Announcement.attribute :tenant, :string + end + + test "MessagePack leaves the attribute alone" do + announcement = Announcement.create!(message: "Announcement 1", tenant: "shared-column-value") + + payload = ActiveSupport::MessagePack::CacheSerializer.dump(announcement) + loaded = ActiveSupport::MessagePack::CacheSerializer.load(payload) + + assert_equal("shared-column-value", loaded.tenant) + end + end +end + +describe "Loading a tenanted record when a callback materializes the attribute set" do + with_scenario(:primary_db, :primary_record) do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + end + + # Only these two formats carry the tenant inside the payload's attributes hash, which is the + # hash the attribute set is built from. + %w[ Marshal MessagePack ].each do |format| + describe format do + let(:serializer) { SERIALIZERS[format] } + + test "the tenant does not become a record attribute" do + User.after_initialize { attribute_names } + + payload = TenantedApplicationRecord.with_tenant("foo") { serializer[:dump].call(User.first) } + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_not_includes(loaded.attribute_names, "tenant") + end + end + end + end +end From c5e8079a9731cac89a56de3d6c9a97e284ef6ae2 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 3 Aug 2026 11:51:47 -0400 Subject: [PATCH 02/11] Guard association tenant context at the query, not at creation 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: https://github.com/basecamp/activerecord-tenanted/issues/243 --- lib/active_record/tenanted/associations.rb | 32 ++++++ lib/active_record/tenanted/railtie.rb | 1 + lib/active_record/tenanted/tenant.rb | 31 +++--- test/unit/serialization_test.rb | 111 +++++++++++++++++++++ test/unit/tenant_test.rb | 73 ++++++++++++-- 5 files changed, 224 insertions(+), 24 deletions(-) create mode 100644 lib/active_record/tenanted/associations.rb diff --git a/lib/active_record/tenanted/associations.rb b/lib/active_record/tenanted/associations.rb new file mode 100644 index 00000000..032419e4 --- /dev/null +++ b/lib/active_record/tenanted/associations.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActiveRecord + module Tenanted + # Tenant context is checked when an association reads or writes the database, and not when the + # Association object is created. Serializers create associations to dump and restore their + # targets without ever going near a connection, and must not be tripped up by the check. + # + # `klass` is resolved at both of these seams, so a polymorphic association is checked against + # the class it actually points at rather than being presumed tenanted. + module Associations # :nodoc: + # Every query built on behalf of an association funnels through here, including the ones + # issued by a collection proxy that has outlived the tenant context it was created in. + def scope + ensure_owner_tenant_context_safety + super + end + + private + # Called by the collection and singular association readers, so that the exception is + # raised at the call site that made the mistake and not at the eventual query. + def ensure_klass_exists! + super + ensure_owner_tenant_context_safety + end + + def ensure_owner_tenant_context_safety + owner.ensure_tenant_context_safety if owner.class.tenanted? && klass&.tenanted? + end + end + end +end diff --git a/lib/active_record/tenanted/railtie.rb b/lib/active_record/tenanted/railtie.rb index b25e07ff..a4292a94 100644 --- a/lib/active_record/tenanted/railtie.rb +++ b/lib/active_record/tenanted/railtie.rb @@ -75,6 +75,7 @@ class Railtie < ::Rails::Railtie ActiveSupport.on_load(:active_record) do prepend ActiveRecord::Tenanted::Base ActiveRecord::Relation.prepend ActiveRecord::Tenanted::Relation + ActiveRecord::Associations::Association.prepend ActiveRecord::Tenanted::Associations end end diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 61ac244c..0595ba41 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -30,11 +30,19 @@ def to_signed_global_id(options = {}) super(options.merge(tenant: tenant)) end - def association(name) - super.tap do |assoc| - if assoc.reflection.polymorphic? || assoc.reflection.klass.tenanted? - ensure_tenant_context_safety - end + # Raises unless the record's tenant is the current tenant. Called before a record writes to + # the database, and by ActiveRecord::Tenanted::Associations before an association reads from + # it. + def ensure_tenant_context_safety + self_tenant = self.tenant + current_tenant = self.class.current_tenant + + if current_tenant.nil? + raise NoTenantError, "Cannot connect to a tenanted database while untenanted (#{self.class})" + elsif self_tenant != current_tenant + raise WrongTenantError, + "#{self.class} model belongs to tenant #{self_tenant.inspect}, " \ + "but current tenant is #{current_tenant.inspect}" end end @@ -90,19 +98,6 @@ def init_internals super end - def ensure_tenant_context_safety - self_tenant = self.tenant - current_tenant = self.class.current_tenant - - if current_tenant.nil? - raise NoTenantError, "Cannot connect to a tenanted database while untenanted (#{self.class})" - elsif self_tenant != current_tenant - raise WrongTenantError, - "#{self.class} model belongs to tenant #{self_tenant.inspect}, " \ - "but current tenant is #{current_tenant.inspect}" - end - end - # Presenting the tenant as one more serializable attribute lets ActiveModel apply the # `only:` and `except:` options to it, and read it back through the `tenant` reader. def attribute_names_for_serialization diff --git a/test/unit/serialization_test.rb b/test/unit/serialization_test.rb index a71d28e5..4c028137 100644 --- a/test/unit/serialization_test.rb +++ b/test/unit/serialization_test.rb @@ -105,6 +105,117 @@ end end +describe "Serializing tenanted records with a loaded association" do + with_scenario(:primary_db, :primary_record) do + setup do + with_migration "20250830152220_create_posts.rb" + User.has_many :posts + Post.belongs_to :user + + TenantedApplicationRecord.create_tenant("foo") do + user = User.create!(email: "user1@foo.example.org") + Post.create!(title: "Post 1 foo", user: user) + Post.create!(title: "Post 2 foo", user: user) + end + + TenantedApplicationRecord.create_tenant("bar") + end + + # Only these two formats put loaded association targets in their payloads, and so only these + # two restore a record by assigning to an association. + %w[ Marshal MessagePack ].each do |format| + describe format do + let(:serializer) { SERIALIZERS[format] } + + let(:dump_in_tenant_context) do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + user.posts.load + serializer[:dump].call(user) + end + end + + let(:dump_outside_tenant_context) do + user = TenantedApplicationRecord.with_tenant("foo") { User.first.tap { |u| u.posts.load } } + TenantedApplicationRecord.without_tenant { serializer[:dump].call(user) } + end + + describe "dumping in a tenanted context" do + test "loading in the same tenant context round-trips" do + payload = dump_in_tenant_context + + TenantedApplicationRecord.with_tenant("foo") do + loaded = serializer[:load].call(payload) + + assert_equal("foo", loaded.tenant) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], loaded.posts.map(&:title)) + assert_equal([ "foo" ], loaded.posts.map(&:tenant).uniq) + end + end + + test "loading outside of a tenanted context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_predicate(loaded.association(:posts), :loaded?) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], + loaded.association(:posts).target.map(&:title)) + end + + test "loading in another tenant context round-trips" do + payload = dump_in_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("bar") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_predicate(loaded.association(:posts), :loaded?) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], + loaded.association(:posts).target.map(&:title)) + end + end + + describe "dumping outside of a tenanted context" do + test "loading in the same tenant context round-trips" do + payload = dump_outside_tenant_context + + TenantedApplicationRecord.with_tenant("foo") do + loaded = serializer[:load].call(payload) + + assert_equal("foo", loaded.tenant) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], loaded.posts.map(&:title)) + assert_equal([ "foo" ], loaded.posts.map(&:tenant).uniq) + end + end + + test "loading outside of a tenanted context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.without_tenant { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_predicate(loaded.association(:posts), :loaded?) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], + loaded.association(:posts).target.map(&:title)) + end + + test "loading in another tenant context round-trips" do + payload = dump_outside_tenant_context + + loaded = TenantedApplicationRecord.with_tenant("bar") { serializer[:load].call(payload) } + + assert_equal("foo", loaded.tenant) + assert_predicate(loaded.association(:posts), :loaded?) + assert_same_elements([ "Post 1 foo", "Post 2 foo" ], + loaded.association(:posts).target.map(&:title)) + end + end + end + end + end +end + describe "Serialization options on a tenanted record" do with_scenario(:primary_db, :primary_record) do setup do diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index d77fd243..be839b83 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -493,6 +493,24 @@ end end end + + test "querying a collection proxy carried into another tenant context" do + proxy = TenantedApplicationRecord.with_tenant("foo") { User.first.posts } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + proxy.count + end + end + end + + test "querying a collection proxy carried outside of a tenant context" do + proxy = TenantedApplicationRecord.with_tenant("foo") { User.first.posts } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + proxy.count + end + end end describe "to an untenanted model" do @@ -537,15 +555,16 @@ end end - describe "polymorphic" do + describe "polymorphic to a tenanted model" do setup do + with_migration "20250830152220_create_posts.rb" with_migration "20250830175957_add_announceable_to_users.rb" - User.belongs_to :announceable, polymorphic: true + User.belongs_to :announceable, polymorphic: true, optional: true TenantedApplicationRecord.create_tenant("foo") do # this association doesn't make a lot of sense, but it's just for testing - announcement = Announcement.create!(message: "Announcement 1") - User.create!(email: "user1@foo.example.org", announceable: announcement) + user = User.create!(email: "user1@foo.example.org") + user.update!(announceable: Post.create!(title: "Post 1 foo", user_id: user.id)) end TenantedApplicationRecord.create_tenant("bar") @@ -554,9 +573,9 @@ test "in a tenanted context" do TenantedApplicationRecord.with_tenant("foo") do user = User.first - announcement = user.announceable + post = user.announceable - assert_equal("Announcement 1", announcement.message) + assert_equal("Post 1 foo", post.title) end end @@ -578,6 +597,48 @@ end end end + + describe "polymorphic to an untenanted model" do + setup do + with_migration "20250830175957_add_announceable_to_users.rb" + User.belongs_to :announceable, polymorphic: true + + TenantedApplicationRecord.create_tenant("foo") do + # this association doesn't make a lot of sense, but it's just for testing + announcement = Announcement.create!(message: "Announcement 1") + User.create!(email: "user1@foo.example.org", announceable: announcement) + end + + TenantedApplicationRecord.create_tenant("bar") + end + + test "in a tenanted context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + announcement = user.announceable + + assert_equal("Announcement 1", announcement.message) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + announcement = user.announceable + + assert_equal("Announcement 1", announcement.message) + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + announcement = TenantedApplicationRecord.with_tenant("bar") do + user.announceable + end + + assert_equal("Announcement 1", announcement.message) + end + end end end From 07adbde8ac250e9f28a6682b8d636f2488100fe1 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 3 Aug 2026 12:15:37 -0400 Subject: [PATCH 03/11] Guard `#reload` against cross-tenant access UntenantedConnectionPool will implement `clear_query_cache` as a no-op. --- lib/active_record/tenanted/tenant.rb | 9 +++-- .../tenanted/untenanted_connection_pool.rb | 3 ++ test/unit/tenant_test.rb | 40 +++++++++++++++++++ test/unit/untenanted_connection_pool_test.rb | 4 ++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 0595ba41..0c45504a 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -30,9 +30,12 @@ def to_signed_global_id(options = {}) super(options.merge(tenant: tenant)) end - # Raises unless the record's tenant is the current tenant. Called before a record writes to - # the database, and by ActiveRecord::Tenanted::Associations before an association reads from - # it. + def reload(...) + ensure_tenant_context_safety + + super + end + def ensure_tenant_context_safety self_tenant = self.tenant current_tenant = self.class.current_tenant diff --git a/lib/active_record/tenanted/untenanted_connection_pool.rb b/lib/active_record/tenanted/untenanted_connection_pool.rb index e306d829..f7e57a7e 100644 --- a/lib/active_record/tenanted/untenanted_connection_pool.rb +++ b/lib/active_record/tenanted/untenanted_connection_pool.rb @@ -32,6 +32,9 @@ def size db_config.max_connections end + def clear_query_cache + end + def lease_connection(...) raise Tenanted::NoTenantError, "Cannot connect to a tenanted database while untenanted (#{@model})." end diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index be839b83..561e5f66 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -1311,6 +1311,46 @@ end end + describe "#reload" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + + assert_equal("user1@foo.example.org", user.reload.email) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.reload + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.reload + end + end + end + end + end + describe "#inspect" do for_each_scenario do describe "created in untenanted context" do diff --git a/test/unit/untenanted_connection_pool_test.rb b/test/unit/untenanted_connection_pool_test.rb index 94c9b170..273a0cec 100644 --- a/test/unit/untenanted_connection_pool_test.rb +++ b/test/unit/untenanted_connection_pool_test.rb @@ -24,5 +24,9 @@ def config.max_connections; 42; end assert_equal 42, subject.size end + + test "clear_query_cache does nothing" do + assert_nil subject.clear_query_cache + end end end From da79b0763b056a52d598b23aec3fb0de06da9bdd Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 3 Aug 2026 12:19:15 -0400 Subject: [PATCH 04/11] Guard `#destroy` and `#delete` against cross-tenant access The `before_save` callback also gains `prepend: true`. --- lib/active_record/tenanted/tenant.rb | 9 +++- test/unit/tenant_test.rb | 80 ++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 0c45504a..dc1ffe80 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -9,7 +9,8 @@ module TenantCommon # :nodoc: prepended do attr_reader :tenant - before_save :ensure_tenant_context_safety + before_save :ensure_tenant_context_safety, prepend: true + before_destroy :ensure_tenant_context_safety, prepend: true end def cache_key @@ -36,6 +37,12 @@ def reload(...) super end + def delete + ensure_tenant_context_safety + + super + end + def ensure_tenant_context_safety self_tenant = self.tenant current_tenant = self.class.current_tenant diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 561e5f66..7a3a0d74 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -1351,6 +1351,86 @@ end end + describe "#destroy" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.destroy + + assert_equal(0, User.count) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.destroy + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.destroy + end + end + end + end + end + + describe "#delete" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.delete + + assert_equal(0, User.count) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.delete + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.delete + end + end + end + end + end + describe "#inspect" do for_each_scenario do describe "created in untenanted context" do From f79e7c3489aa67a22068e85e8123ba5e29d10fec Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 3 Aug 2026 16:31:09 -0400 Subject: [PATCH 05/11] Update AR equality methods to respect tenant. --- lib/active_record/tenanted/tenant.rb | 9 ++++++ test/unit/tenant_test.rb | 47 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index dc1ffe80..84db799c 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -17,6 +17,15 @@ def cache_key tenant ? "#{tenant}/#{super}" : super end + def ==(other) + super && tenant == other.tenant + end + alias eql? == + + def hash + [ super, tenant ].hash + end + def inspect return super unless tenant diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 7a3a0d74..ab57d326 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -1276,6 +1276,53 @@ end end + describe "#==" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + let(:foo_user) { TenantedApplicationRecord.with_tenant("foo") { User.first } } + let(:bar_user) { TenantedApplicationRecord.with_tenant("bar") { User.first } } + + test "records from the same tenant with the same id are equal" do + other = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_equal(foo_user, other) + end + + test "records from the same tenant with the same id have the same hash" do + other = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_equal(foo_user.hash, other.hash) + end + + test "records from different tenants with the same id are not equal" do + assert_equal(foo_user.id, bar_user.id) + + assert_not_equal(foo_user, bar_user) + end + + test "records from different tenants with the same id are not eql?" do + assert_equal(foo_user.id, bar_user.id) + + assert_not(foo_user.eql?(bar_user)) + end + + test "records from different tenants with the same id have different hashes" do + assert_equal(foo_user.id, bar_user.id) + + assert_not_equal(foo_user.hash, bar_user.hash) + end + end + end + describe "#cache_key" do for_each_scenario do describe "created in untenanted context" do From ebfe836705db32351f4247e6063f160fbdac5001 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 4 Aug 2026 09:41:37 -0400 Subject: [PATCH 06/11] Guard `belongs_to` assignment against cross-tenant records --- lib/active_record/tenanted/tenant.rb | 18 ++++ test/unit/tenant_test.rb | 134 +++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 84db799c..0f26b09d 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -9,6 +9,7 @@ module TenantCommon # :nodoc: prepended do attr_reader :tenant + before_save :ensure_belongs_to_tenant_safety, prepend: true before_save :ensure_tenant_context_safety, prepend: true before_destroy :ensure_tenant_context_safety, prepend: true end @@ -52,6 +53,23 @@ def delete super end + # A belongs_to writes its foreign key onto this record, so nothing else notices when the + # target came from another tenant's database. + def ensure_belongs_to_tenant_safety + self.class.reflect_on_all_associations(:belongs_to).each do |reflection| + # Avoid creating the Association object for a belongs_to that was never touched + next unless association_cached?(reflection.name) + + target = association(reflection.name).target + + if target && target.class.tenanted? && target.tenant != tenant + raise WrongTenantError, + "#{self.class} model belongs to tenant #{tenant.inspect}, but its " \ + "#{reflection.name} association belongs to tenant #{target.tenant.inspect}" + end + end + end + def ensure_tenant_context_safety self_tenant = self.tenant current_tenant = self.class.current_tenant diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index ab57d326..c8181dec 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -643,6 +643,140 @@ end + describe "association setters" do + for_each_scenario do + describe "to a tenanted model" do + setup do + with_migration "20250830152220_create_posts.rb" + Post.belongs_to :user + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + user = User.create!(email: "user1@bar.example.org") + Post.create!(title: "Post 1 bar", user: user) + end + end + + let(:foo_user) { TenantedApplicationRecord.with_tenant("foo") { User.first } } + let(:bar_post) { TenantedApplicationRecord.with_tenant("bar") { Post.first } } + + describe "belongs_to" do + test "assigning a record from another tenant does not raise" do + user = foo_user + + TenantedApplicationRecord.with_tenant("bar") do + post = Post.first + + assert_nothing_raised { post.user = user } + end + end + + test "saving after assigning a record from another tenant raises" do + user = foo_user + + TenantedApplicationRecord.with_tenant("bar") do + post = Post.first + post.user = user + + assert_raises(ActiveRecord::Tenanted::WrongTenantError) { post.save } + end + end + + test "saving with a cached association from the same tenant does not raise" do + TenantedApplicationRecord.with_tenant("bar") do + post = Post.first + post.user = User.first + + assert_nothing_raised { post.update!(title: "Post 1 bar, edited") } + end + end + + test "saving with an association that was never loaded does not raise" do + TenantedApplicationRecord.with_tenant("bar") do + post = Post.first + + assert_nothing_raised { post.update!(title: "Post 1 bar, edited") } + end + end + end + + describe "has_many" do + setup { User.has_many :posts } + + test "assigning a record from another tenant does not raise" do + post = bar_post + + TenantedApplicationRecord.with_tenant("foo") do + user = User.new(email: "user2@foo.example.org") + + assert_nothing_raised { user.posts = [ post ] } + end + end + + test "saving after assigning a record from another tenant raises" do + post = bar_post + + TenantedApplicationRecord.with_tenant("foo") do + user = User.new(email: "user2@foo.example.org") + user.posts = [ post ] + + assert_raises(ActiveRecord::Tenanted::WrongTenantError) { user.save } + end + end + end + + describe "has_one" do + setup { User.has_one :post } + + test "assigning a record from another tenant does not raise" do + post = bar_post + + TenantedApplicationRecord.with_tenant("foo") do + user = User.new(email: "user2@foo.example.org") + + assert_nothing_raised { user.post = post } + end + end + + test "saving after assigning a record from another tenant raises" do + post = bar_post + + TenantedApplicationRecord.with_tenant("foo") do + user = User.new(email: "user2@foo.example.org") + user.post = post + + assert_raises(ActiveRecord::Tenanted::WrongTenantError) { user.save } + end + end + end + end + + describe "to an untenanted model" do + setup do + with_migration "20250830170325_add_announcement_to_users.rb" + User.belongs_to :announcement + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org", + announcement: Announcement.create!(message: "Announcement 1")) + end + end + + test "saving after assigning an untenanted record does not raise" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + user.announcement = Announcement.create!(message: "Announcement 2") + + assert_nothing_raised { user.save! } + end + end + end + end + end + describe ".without_tenant" do for_each_scenario do setup do From bb3c09c7c99970efc04f5133dba875a3496fa4cd Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 4 Aug 2026 09:48:24 -0400 Subject: [PATCH 07/11] Guard the callback-free write methods against cross-tenant access - `#update_column` - `#update_columns` - `#touch` - `#increment!` - `#decrement!` --- lib/active_record/tenanted/tenant.rb | 20 +++ test/unit/tenant_test.rb | 207 +++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 0f26b09d..73c13d5e 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -53,6 +53,26 @@ def delete super end + # #update_column delegates here + def update_columns(...) + ensure_tenant_context_safety + + super + end + + def touch(...) + ensure_tenant_context_safety + + super + end + + # #decrement! delegates here + def increment!(...) + ensure_tenant_context_safety + + super + end + # A belongs_to writes its foreign key onto this record, so nothing else notices when the # target came from another tenant's database. def ensure_belongs_to_tenant_safety diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index c8181dec..0d2d0c8c 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -1612,6 +1612,213 @@ end end + describe "#update_column" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.update_column(:email, "edited@foo.example.org") + + assert_equal("edited@foo.example.org", User.first.email) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.update_column(:email, "edited@foo.example.org") + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.update_column(:email, "edited@foo.example.org") + end + end + end + end + end + + describe "#update_columns" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.update_columns(email: "edited@foo.example.org") + + assert_equal("edited@foo.example.org", User.first.email) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.update_columns(email: "edited@foo.example.org") + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.update_columns(email: "edited@foo.example.org") + end + end + end + end + end + + describe "#touch" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + was = user.updated_at + + user.touch + + assert_operator(user.updated_at, :>, was) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.touch + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.touch + end + end + end + end + end + + describe "#increment!" do + for_each_scenario do + setup do + with_migration "20250213005959_add_age_to_users.rb" + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org", age: 10) + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org", age: 20) + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.increment!(:age) + + assert_equal(11, User.first.age) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.increment!(:age) + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.increment!(:age) + end + end + end + end + end + + describe "#decrement!" do + for_each_scenario do + setup do + with_migration "20250213005959_add_age_to_users.rb" + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org", age: 10) + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org", age: 20) + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + User.first.decrement!(:age) + + assert_equal(9, User.first.age) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.decrement!(:age) + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.decrement!(:age) + end + end + end + end + end + describe "#inspect" do for_each_scenario do describe "created in untenanted context" do From a84a2a20baed2b54f306fc2db1e531a7c1599dd8 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 4 Aug 2026 10:23:19 -0400 Subject: [PATCH 08/11] Guard `#valid?` against cross-tenant access The `before_save` guards are now exercised with `validate: false`, since `#save` validates before running its callbacks. --- lib/active_record/tenanted/tenant.rb | 6 ++ test/unit/tenant_test.rb | 89 +++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index 73c13d5e..dbc6291c 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -47,6 +47,12 @@ def reload(...) super end + def valid?(...) + ensure_tenant_context_safety + + super + end + def delete ensure_tenant_context_safety diff --git a/test/unit/tenant_test.rb b/test/unit/tenant_test.rb index 0d2d0c8c..f249323d 100644 --- a/test/unit/tenant_test.rb +++ b/test/unit/tenant_test.rb @@ -674,14 +674,14 @@ end end - test "saving after assigning a record from another tenant raises" do + test "saving with validate: false after assigning a record from another tenant raises" do user = foo_user TenantedApplicationRecord.with_tenant("bar") do post = Post.first post.user = user - assert_raises(ActiveRecord::Tenanted::WrongTenantError) { post.save } + assert_raises(ActiveRecord::Tenanted::WrongTenantError) { post.save(validate: false) } end end @@ -1819,6 +1819,91 @@ end end + describe "#save with validate: false" do + for_each_scenario do + setup do + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + user.email = "edited@foo.example.org" + + assert(user.save(validate: false)) + assert_equal("edited@foo.example.org", User.first.email) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.save(validate: false) + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.save(validate: false) + end + end + end + end + end + + describe "#valid?" do + for_each_scenario do + setup do + User.validates_uniqueness_of :email + + TenantedApplicationRecord.create_tenant("foo") do + User.create!(email: "user1@foo.example.org") + end + + TenantedApplicationRecord.create_tenant("bar") do + User.create!(email: "user1@bar.example.org") + end + end + + test "in the same tenant context" do + TenantedApplicationRecord.with_tenant("foo") do + user = User.first + user.email = "edited@foo.example.org" + + assert_predicate(user, :valid?) + end + end + + test "outside of a tenanted context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + assert_raises(ActiveRecord::Tenanted::NoTenantError) do + user.valid? + end + end + + test "in another tenant context" do + user = TenantedApplicationRecord.with_tenant("foo") { User.first } + + TenantedApplicationRecord.with_tenant("bar") do + assert_raises(ActiveRecord::Tenanted::WrongTenantError) do + user.valid? + end + end + end + end + end + describe "#inspect" do for_each_scenario do describe "created in untenanted context" do From c4b4e46166fda7f47526ba6e9792a15e8ebdbac6 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 4 Aug 2026 11:07:55 -0400 Subject: [PATCH 09/11] ci: add msgpack to appraisal gemfiles --- gemfiles/rails_8_1.gemfile | 1 + gemfiles/rails_edge.gemfile | 1 + 2 files changed, 2 insertions(+) diff --git a/gemfiles/rails_8_1.gemfile b/gemfiles/rails_8_1.gemfile index 0c84ff17..32c62cad 100644 --- a/gemfiles/rails_8_1.gemfile +++ b/gemfiles/rails_8_1.gemfile @@ -22,6 +22,7 @@ group :development, :test do gem "minitest", "6.0.6" gem "minitest-mock", "5.27.0" gem "minitest-parallel_fork", "2.1.1", require: false + gem "msgpack", "1.8.3" gem "sqlite3", "2.9.4" end diff --git a/gemfiles/rails_edge.gemfile b/gemfiles/rails_edge.gemfile index fcfab00e..09529d2e 100644 --- a/gemfiles/rails_edge.gemfile +++ b/gemfiles/rails_edge.gemfile @@ -22,6 +22,7 @@ group :development, :test do gem "minitest", "6.0.6" gem "minitest-mock", "5.27.0" gem "minitest-parallel_fork", "2.1.1", require: false + gem "msgpack", "1.8.3" gem "sqlite3", "2.9.4" end From 42213355b50f410dce9905bf6721732b2bcaf185 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 4 Aug 2026 12:49:54 -0400 Subject: [PATCH 10/11] Keep a `nil` tenant across a serialization round trip 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. --- lib/active_record/tenanted/message_pack.rb | 3 ++- lib/active_record/tenanted/tenant.rb | 10 ++++----- test/unit/serialization_test.rb | 24 ++++++++++++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/lib/active_record/tenanted/message_pack.rb b/lib/active_record/tenanted/message_pack.rb index ea926afc..999511ef 100644 --- a/lib/active_record/tenanted/message_pack.rb +++ b/lib/active_record/tenanted/message_pack.rb @@ -10,10 +10,11 @@ def build_record(entry) class_name, attributes_hash, * = entry if ActiveSupport::MessagePack::Extensions.load_class(class_name).tenanted? + has_tenant = attributes_hash.key?("tenant") tenant_name = attributes_hash.delete("tenant") super.tap do |record| - record.instance_variable_set(:@tenant, tenant_name) if tenant_name + record.instance_variable_set(:@tenant, tenant_name) if has_tenant end else super diff --git a/lib/active_record/tenanted/tenant.rb b/lib/active_record/tenanted/tenant.rb index dbc6291c..c8ed1fd4 100644 --- a/lib/active_record/tenanted/tenant.rb +++ b/lib/active_record/tenanted/tenant.rb @@ -124,8 +124,8 @@ def encode_with(coder) def init_with(coder, &block) super - tenant_name = coder["tenant"] - @tenant = tenant_name if tenant_name + # Psych::Coder does not implement #key?, but exposes the underlying hash as #map + @tenant = coder.map["tenant"] if coder.map.key?("tenant") self end @@ -134,19 +134,19 @@ def from_json(json, include_root = include_root_in_json) hash = ActiveSupport::JSON.decode(json) hash = hash.values.first if include_root - tenant_name = hash.delete("tenant") - @tenant = tenant_name if tenant_name + @tenant = hash.delete("tenant") if hash.key?("tenant") self.attributes = hash self end def marshal_load(state) + has_tenant = state[0].key?("tenant") tenant_name = state[0].delete("tenant") super - @tenant = tenant_name if tenant_name + @tenant = tenant_name if has_tenant end alias to_gid to_global_id diff --git a/test/unit/serialization_test.rb b/test/unit/serialization_test.rb index 4c028137..546688c0 100644 --- a/test/unit/serialization_test.rb +++ b/test/unit/serialization_test.rb @@ -296,3 +296,27 @@ end end end + +describe "Serializing a tenanted record with no tenant" do + with_scenario(:primary_db, :primary_record) do + setup do + with_schema_cache_dump_file + TenantedApplicationRecord.create_tenant("foo") + end + + SERIALIZERS.each do |format, serializer| + describe format do + test "the loading context does not become the record's tenant" do + user = User.new(email: "user1@example.org") + assert_nil(user.tenant) + + payload = serializer[:dump].call(user) + + loaded = TenantedApplicationRecord.with_tenant("foo") { serializer[:load].call(payload) } + + assert_nil(loaded.tenant) + end + end + end + end +end From a7943fa9e2a387274a6d43980fe65b209bc89436 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 4 Aug 2026 13:10:51 -0400 Subject: [PATCH 11/11] doc: Update CHANGELOG.md --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3395e02..7123a3d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,30 @@ ## next / unreleased +### Breaking change: serialized payloads carry the tenant + +A tenanted record now writes its tenant into the payload produced by `Marshal`, JSON, YAML, and MessagePack, so that it keeps its tenant identity when it is loaded in another tenant context or in none. This fixes a hang in parallel test suites, where a worker died reporting an exception that carried a record with a loaded association. See [#243](https://github.com/basecamp/activerecord-tenanted/issues/243). @flavorjones + +The payload format changes, and the change is not backward compatible. An earlier version of this gem reading a payload written by this version raises `ActiveModel::UnknownAttributeError` for JSON, and gains a phantom `tenant` attribute for `Marshal` and MessagePack. Payloads written by an earlier version are still read correctly by this version, and keep the loading context as they did before. + +Discard persisted payloads before upgrading, including cache entries, enqueued jobs, and serialized columns. Expect the same incompatibility during a rolling deploy, while old and new code run at the same time. + +### Breaking change: record equality + +`==`, `eql?`, and `#hash` now take the tenant into account. Two records of the same class with the same id in different tenant databases were previously equal, and collided as `Hash` keys and in a `Set`. They are now distinct. Code that compares records across tenants, or that relies on `has_many` assignment treating them as interchangeable, will behave differently. @flavorjones + ### Fixed - `Tenanted::GlobalId::Locator` now inherits from `GlobalID::Locator::UnscopedLocator`, which is the locator Rails uses by default. Two behavior changes follow: GlobalID lookups no longer apply a model's `default_scope`, matching Rails; and `#locate_many` is now implemented, which Active Job on Rails edge requires to deserialize GlobalID arguments. `#locate_many` enforces the same tenant safety checks as `#locate`. @flavorjones - `Tenanted::GlobalId::Locator` no longer emits a deprecation warning from GlobalID 1.4.0 about the missing `model_class` method. @flavorjones +- The tenant context check now runs when an association reads or writes the database, rather than when the `Association` object is created. A polymorphic association is checked against the class it points at, instead of being presumed tenanted. @flavorjones +- `#reload`, `#destroy`, `#delete`, `#update_column`, `#update_columns`, `#touch`, `#increment!`, `#decrement!`, and `#valid?` check the tenant context before they touch the database. They previously operated on the current tenant's database whatever the record's tenant. @flavorjones +- Assigning a `belongs_to` target that belongs to another tenant now raises `WrongTenantError` when the record is saved, rather than writing a foreign key that points into another tenant's database. @flavorjones +- `UntenantedConnectionPool` implements `#clear_query_cache` as a no-op, having no query cache to clear. `ActiveRecord::Persistence#reload` calls it before it asks for a connection, so an untenanted `#reload` previously raised `NoMethodError` instead of `NoTenantError`. @flavorjones + +### Developer infrastructure + +- Added `msgpack` as a development dependency, so that the serialization tests can exercise a MessagePack round trip. @flavorjones ## v0.7.0 / 2026-06-08