ros-apartment 4.0.0.alpha12 → 4.0.0.alpha13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2bb81b710aa16f388e8861ad84d358eee5ec751ff6cce1ad3121513e9a0ef46e
4
- data.tar.gz: 49adf108763c408b893a314f223756697ccaf21b950087656b357aeb93f33dcd
3
+ metadata.gz: b8e5931794be153df0c2443c215f142fa9f07c8e0a0830f5efac616e0c306e58
4
+ data.tar.gz: e97bbef33052e8bae6a0ce036afc61aeb19a9b1a04895ddcdfb138b5bf89f031
5
5
  SHA512:
6
- metadata.gz: 773ee7eff98a2f2287c53aef4b1b8172c97b2251c3d82e3f4b099e60b9602f7093cec92b8bdef9669d71a864bece4f767a2ba7dad9109816a80e8478b6087ed7
7
- data.tar.gz: 1cf4884cd5b476e57481e8f928c39575e95cdc75249b86fa24dbfeb6885d28f9af1567795f22d1d71270cd402755711727f6614ecc566998d85a47cae780635c
6
+ metadata.gz: 362d681d9a275c070521d0f7dff72d8562e3395262df60ea1d61d8a6865956cc700047b5e12a99b132c6a51c41bdaf813592c434f0ca8f10dcdaef65bc876dbb
7
+ data.tar.gz: 56ab684ddbdc3db9ec820a5b833538fedf3e10ae6358edc0283020cbd352578870d9277620d4fe230b34f13308f2c7d0382c810cba66f2ac8a5d60947ea277a2
data/README.md CHANGED
@@ -182,9 +182,20 @@ See the [Elevators](#elevators) section for available options.
182
182
 
183
183
  ### RBAC
184
184
 
185
- `migration_role`: a Symbol naming the database role used for migrations (default: nil, uses the connection's default role).
185
+ Apartment guarantees which role executes tenant DDL and leaves privilege policy to you. Full guide: [docs/rbac.md](docs/rbac.md).
186
186
 
187
- `app_role`: a String or callable returning the restricted role for application queries (default: nil).
187
+ `ddl_role`: a Symbol naming an ActiveRecord `connects_to` role used for all tenant DDL (default: nil, uses the connection's default role). It covers migrations, tenant creation and tenant drop alike: the container, both privilege-policy phases, and any `schema_load_strategy` import all run on it. Seeding does not — rows carry no ownership.
188
+
189
+ `tenant_privilege_policy`: a callable invoked twice per create, once before the schema import and once after, with a context carrying the tenant, the physical container name, the connection, the resolved database role and the phase (default: nil). Apartment issues no grants of its own; a policy runs only because you configured one.
190
+
191
+ ```ruby
192
+ Apartment.configure do |config|
193
+ config.ddl_role = :db_manager
194
+ config.tenant_privilege_policy = Apartment::Privileges.standard(grant_to: 'app_user')
195
+ end
196
+ ```
197
+
198
+ `Apartment::Privileges.standard` ships the engine-specific grant SQL as a library rather than as implicit behaviour, including PostgreSQL's `ALTER DEFAULT PRIVILEGES FOR ROLE`, which with no `FOR ROLE` is scoped to whichever role executed it and so has to name the database principal `ddl_role` resolves to. Two phases because position is policy: a default-privileges-only model must record its rules before the schema import, and a model granting existing objects must run after. [docs/rbac.md](docs/rbac.md) covers writing your own policy, the MySQL `GRANT OPTION` prerequisite, and what happens when a policy raises.
188
199
 
189
200
  ### PostgreSQL
190
201
 
@@ -23,14 +23,18 @@ lib/apartment/
23
23
  │ ├── connection_handling.rb # Prepends on AR::Base — tenant-aware connection_pool
24
24
  │ ├── connection_registry.rb # Prepends on AR's PoolManager + ConnectionHandler — serializes the pool registry
25
25
  │ └── postgresql_sequence_name.rb # Prepends on the PG adapter — schema-agnostic Model.sequence_name memoization
26
+ ├── privileges/ # Tenant privilege policy support
27
+ │ └── context.rb # Privileges::Context: what a tenant_privilege_policy receives, one per phase
26
28
  ├── tasks/ # Rake task utilities; v4.rake for apartment:create/drop/migrate/seed/rollback
27
29
  ├── config.rb # Configuration with validate!/freeze!
28
30
  ├── current.rb # Fiber-safe tenant context (CurrentAttributes)
29
31
  ├── errors.rb # Exception hierarchy
30
32
  ├── instrumentation.rb # ActiveSupport::Notifications wrapper
33
+ ├── migration_role.rb # Runs a block on config.ddl_role (shared by Migrator, CLI, adapters)
31
34
  ├── migrator.rb # Migration orchestrator: sequential/parallel, Result/MigrationRun value objects
32
35
  ├── pool_manager.rb # Concurrent::Map pool cache with monotonic timestamps
33
36
  ├── pool_reaper.rb # Background idle/LRU pool eviction
37
+ ├── privileges.rb # Privileges.standard: prebuilt tenant_privilege_policy factory
34
38
  ├── railtie.rb # Rails initialization (activate!, middleware, rake tasks)
35
39
  ├── schema_dumper_patch.rb # Rails 8.1 schema dump fix: strips public. prefix from table names
36
40
  ├── tenant.rb # Public API facade (switch, current, reset, lifecycle)
@@ -97,12 +101,14 @@ All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `creat
97
101
 
98
102
  **Identity:** `apartment_pinned?` — the class answers whether it is pinned (ivars + superclass walk). `Apartment.pinned_model?(klass)` delegates to `klass.apartment_pinned?` when the concern is included; otherwise it falls back to registry lookup (`pinned_models`) for `excluded_models` shim classes that never included the concern.
99
103
 
100
- **Table naming:** `apartment_explicit_table_name?` — whether the cached `@table_name` is one Rails' convention machinery would rebuild (compares `@table_name` to `compute_table_name`). Lives here so adapters do not read `@table_name` or call `compute_table_name` from outside; **class instance variable access for pinning is confined to this concern**. It selects a **restore** strategy only it is *not* a qualification discriminator, and using it as one shipped three silent no-ops (see `docs/designs/v4-shared-pinned-connections.md`): `compute_table_name` honours `full_table_name_prefix` only on its `base_class?` branch, so prefix-based qualification is discarded outright for subclasses and for models whose module parent defines `table_name_prefix`. Qualification always assigns `table_name` directly.
104
+ **Table naming:** `apartment_explicit_table_name?` — whether the cached `@table_name` is one Rails' convention machinery would rebuild (compares `@table_name` to `compute_table_name`). Lives here so adapters do not read `@table_name` or call `compute_table_name` from outside; **class instance variable access for pinning is confined to this concern**. It answers one question — "would convention rebuild this cached name?" — and that answer feeds three decisions: the restore strategy (`:explicit` vs `:computed`), whether a subclass shares its base's table (`inherits_pinned_table?`), and whether an unregistered descendant declared its own (`unregistered_pinned_subclass?`). It must **not** select the qualification *strategy* — assignment vs `table_name_prefix` — which is the misuse that shipped three silent no-ops (see `docs/designs/v4-shared-pinned-connections.md`): `compute_table_name` honours `full_table_name_prefix` only on its `base_class?` branch, so prefix-based qualification is discarded outright for subclasses and for models whose module parent defines `table_name_prefix`. Qualification always assigns `table_name` directly.
101
105
 
102
106
  **Lifecycle:** `apartment_pinned_processed?`, `apartment_mark_processed!`, `apartment_restore!` — qualification state and teardown. Paths are `:computed` (convention rebuilds the name; restore drops the `@table_name` override and recomputes), `:explicit` (restore assigns the saved name back verbatim), `:prefix` (abstract base; restore puts back the app's `table_name_prefix`), and `nil` (separate-pool; nothing to undo). **Abstract bases are the one case still qualified via `table_name_prefix`**, because `pin_tenant` early-returns once a superclass is pinned — so concrete descendants are never registered and only a `class_attribute` broadcast reaches them. Adapters call these; `Apartment.clear_config` uses `apartment_restore!` with `respond_to?` so shim-registered models without the concern still clear safely. `apartment_mark_pinned!` — sets the pinned flag without triggering processing (used by `process_pinned_model` for shim classes to avoid `pin_tenant` recursion).
103
107
 
104
108
  **Subclasses of a pinned model:** `pin_tenant` is idempotent **per class**, not per hierarchy — it keys on the class's own flag, not `apartment_pinned?` (which walks the superclass chain). A subclass declaring its own table must register and qualify on its own merits, since the parent's qualification cannot reach a different table; keying on the chain made that call silently no-op. A subclass that *shares* the parent's table still needs nothing and is skipped at qualification time by `AbstractAdapter#inherits_pinned_table?`. A subclass that declares its own table and is never registered gets a boot warning (`warn_unregistered_pinned_subclasses`, descendants-based, so complete only under eager loading). See `docs/designs/v4-shared-pinned-connections.md`.
105
109
 
110
+ **Qualification proves itself:** `verify_pinned_qualification!` checks the post-condition after qualifying — the table name actually carries the qualifier. The **registered model raises** (`ConfigurationError`); its **descendants only warn**. That split is the rule "raise on what you can prove": the model's own check is complete and unambiguous, while the descendant walk is `descendants`-based and so complete only under eager loading — the same reasoning `warn_unregistered_pinned_subclasses` documents. Descendants that are themselves registered but not yet processed are skipped (they are qualified on their own turn); the test is registry membership, **not** `apartment_pinned?`, which walks the superclass chain and would skip every descendant.
111
+
106
112
  **Descendant memos:** Rails memoizes `@table_name` per class and never invalidates a descendant's copy when an ancestor changes, so an early read (initializer, gem, route constraint) would freeze the *unqualified* name and the pinned model would read the tenant's table forever. `qualify_pinned_table_name` and `apartment_restore!` bracket their mutation with `apartment_descendants_inheriting_table_name` (collected **before**, while a stale memo is still distinguishable from a declaration) and `apartment_resync_descendant_table_names!` (`reset_table_name` after, which clears `@quoted_table_name`/`@arel_table` via Rails' own setter).
107
113
 
108
114
  **Guards:** `pin_tenant` raises `ArgumentError` if called on a non-AR class or module. For anonymous classes (`Class.new`), it warns that `TracePoint(:end)` won't fire and skips deferral; call `process_pinned_model` explicitly after assigning the constant.
@@ -126,6 +132,18 @@ Three hooks in Rails boot order:
126
132
 
127
133
  `Apartment::Migrator` runs migrations across all tenants with optional thread-based parallelism. Delegates to `Apartment::Tenant.switch` for each tenant — the `ConnectionHandling` patch routes `AR::Base.connection_pool` to the tenant's pool, so Rails' migration machinery (which hardcodes `AR::Base.lease_connection`) uses the correct connection automatically. No standalone pools or handler swaps. Disables PG advisory locks for tenant migrations (database-wide locks serialize parallel execution; see issue #298). `Result` (Data.define) tracks per-tenant success/failure/skip. `MigrationRun` aggregates results with `#success?`, `#summary`. Primary migration aborts the run on failure (tenants are never touched). Constructor accepts `threads:` (0=sequential). RBAC credential separation (`migration_db_config`) is deferred to Phase 5.
128
134
 
135
+ ### migration_role.rb — DDL Role Wrap
136
+
137
+ `Apartment::MigrationRole.wrap` runs a block inside `connected_to(role: config.ddl_role)`, or yields when no role is configured. It exists as its own module because both `Migrator` and the adapters need it and an adapter cannot depend on `Migrator`; `Migrator.with_migration_role` stays as the documented entry point and delegates here. All tenant DDL goes through it — migrations, `Tenant.create`, and `Tenant.drop`'s engine call — because PostgreSQL scopes an `ALTER DEFAULT PRIVILEGES` rule with no `FOR ROLE` to the role that executed it, and because `DROP SCHEMA` needs ownership of a container `ddl_role` owns. See `docs/designs/v4-rbac-contract.md`.
138
+
139
+ An unresolvable role is translated here: `ActiveRecord::ConnectionNotEstablished` becomes an `Apartment::ConfigurationError` naming `ddl_role` and the symbol given. `connected_to` resolves no pool — it pushes onto `connected_to_stack` and yields — so the failure arrives from *inside* the block and cannot be detected before it; a wrap whose block never touches the database stays silent by design. What discriminates is not the error class but a probe: `retrieve_connection_pool` for our role, nil meaning the failure is ours to explain and a pool meaning it belongs to the caller's block and re-raises untouched. The rescue names `ConnectionNotEstablished` and **only** that class, because `ConnectionNotDefined` does not exist before Rails 8.0 and Ruby resolves rescue constants at raise time, so naming the subclass raised `NameError` on the Rails floor and destroyed the error it was classifying. No wrapped error still needs translating: `Patches::ConnectionHandling#connection_pool` scopes its relabelling rescue to the tenant-resolution path, so errors do still arrive wrapped from inside it — but those are genuine tenant-pool failures that belong to the tenant rather than to `ddl_role`, and translating them would misattribute. A ddl_role failure is not one of them: it surfaces from the default-path lookup outside that boundary, and the tenant path establishes a pool for the role itself so it never fails on an unregistered one. The `ApartmentError` clause and one-layer unwrap this rescue once carried belonged to the old method-level rescue and went with it. Checked at first use rather than at `activate!`, which runs in `after_initialize`, after the eager-load initializer: under lazy loading no model has run `connects_to` yet and a boot-time check would fail on every boot.
140
+
141
+ ### privileges.rb / privileges/context.rb — Adopter-Owned Privilege Policy
142
+
143
+ `Apartment::Privileges.standard(grant_to:, include_functions: true)` returns a callable suitable for `config.tenant_privilege_policy`. It owns its own phase mapping — default-privileges rules before the schema import, grants on existing objects after — so an adopter never re-derives which statements belong where. It validates `grant_to` when the policy is built, not when a tenant is created, and resolves `Apartment.adapter` at call time (the adapter is not set at `configure` time). The SQL itself lives behind the adapter seam `#standard_privilege_statements`; see `adapters/CLAUDE.md`.
144
+
145
+ `Privileges::Context` is what a policy receives, one instance per phase, frozen. It is deliberately **not** `Data.define` even though `Data` is house style for value objects (`Migrator::Result`, `PoolObserver::Sample`): it carries a live connection, so value equality, hashing and positional decomposition are wrong semantics, and `Data` cannot deliver the additive-only promise — appending a member adds a required positional argument and a required keyword to `.new`, and `Data` responds to `#deconstruct`. New fields arrive as keyword arguments with defaults and unknown keywords are ignored, so a policy reading attributes off a context keeps working. Construction is the gem's business. Full contract: `docs/rbac.md`, rationale in `docs/designs/v4-rbac-contract.md`.
146
+
129
147
  ### schema_dumper_patch.rb — Rails 8.1 Schema Fix
130
148
 
131
149
  Patches `ActiveRecord::SchemaDumper` to strip `public.` prefix from table names in `schema.rb` output. Applied conditionally for Rails 8.1+ via `SchemaDumperPatch.apply!` (called by Railtie). Respects `PostgresqlConfig#include_schemas_in_dump` for non-public schemas that should retain their prefix.
@@ -65,6 +65,19 @@ AbstractAdapter
65
65
 
66
66
  **Tenant creation**: Runs callbacks, creates tenant via subclass, switches context, imports schema, optionally seeds data. See `AbstractAdapter#create` method.
67
67
 
68
+ The create sequence inside `#run_tenant_ddl`, all of it on `config.ddl_role`: `create_tenant`, the policy at `:before_schema_load`, the schema import when `schema_load_strategy` is set, then the policy at `:after_schema_load`. Seeding runs outside that wrap, because rows carry no ownership. `#drop` wraps its `drop_tenant` call for the same ownership reason and leaves the pool removal and shard deregistration outside. PostgreSQL scopes an `ALTER DEFAULT PRIVILEGES` rule with no `FOR ROLE` to the role that executed it, so creation and migrations must share one role.
69
+
70
+ Two phases because position is policy: a default-privileges-only model has to record its rules before the import or imported tables fall outside them, while a model granting existing objects has to run after. Both fire even when no schema is loaded. The database role the policy is told about is resolved once per create, inside the wrap, and passed to both phases as an argument — never memoized on the adapter, which is one instance per process and therefore shared across concurrent creates.
71
+
72
+ ### Privilege Seams (Custom Adapters Implement These)
73
+
74
+ - `#standard_privilege_statements(ctx, grant_to:, include_functions: true)` → `Array<String>`. Builds the statements for `ctx.phase` and returns them; it does not execute, so the SQL unit-tests without a database. Return `[]` for a phase the engine does not need, and branch on `ctx.phase` by name with a raising `else` rather than falling out of a predicate guard — a silent nothing is the defect this contract replaces. The base raises `Apartment::ConfigurationError`, not `NotImplementedError`: the latter descends from `ScriptError`, so an adopter's `rescue StandardError` around `Tenant.create` would miss it. `PostgresqlSchemaAdapter` and `Mysql2Adapter` implement it; `PostgresqlDatabaseAdapter` and `Sqlite3Adapter` inherit the raise as a reasoned exclusion, each pinned by its own spec.
75
+ - `#current_db_role(connection)` → `String` or `nil`. The executing database role, for policies that name it explicitly. The token shape differs by engine, which is why each adapter answers: PostgreSQL returns `current_user`, MySQL returns `role@host`, the base returns nil.
76
+
77
+ Quote role names with `quote_column_name`, not `quote_table_name`: the latter splits on dots, and a legal role like `svc.migrator` would come back as two identifiers. Container names go through `quoted_container`, which is safe because they pass `TenantNameValidator` and it rejects dots.
78
+
79
+ Guide: `docs/rbac.md`. Rationale: `docs/designs/v4-rbac-contract.md`.
80
+
68
81
  **Tenant switching**: Stores previous tenant, switches, yields to block, ensures rollback in ensure clause with fallback to default. See `AbstractAdapter#switch` method.
69
82
 
70
83
  **Schema import**: Loads `db/schema.rb` or custom schema file. See schema import logic in `abstract_adapter.rb`.
@@ -58,18 +58,22 @@ module Apartment
58
58
  strategy: Apartment.config.tenant_strategy,
59
59
  adapter_name: base_config['adapter']
60
60
  )
61
- run_callbacks(:create) do
62
- create_tenant(tenant)
63
- grant_tenant_privileges(tenant)
64
- import_schema(tenant) if Apartment.config.schema_load_strategy
65
- seed(tenant) if Apartment.config.seed_after_create
66
- Instrumentation.instrument(:create, tenant: tenant)
61
+ suppressing_pending_migration_check do
62
+ run_callbacks(:create) do
63
+ run_tenant_ddl(tenant)
64
+ seed(tenant) if Apartment.config.seed_after_create
65
+ Instrumentation.instrument(:create, tenant: tenant)
66
+ end
67
67
  end
68
68
  end
69
69
 
70
70
  # Drop a tenant.
71
71
  def drop(tenant)
72
- drop_tenant(tenant)
72
+ # Wrapped for the same reason create is: the container is owned by ddl_role,
73
+ # and DROP SCHEMA requires ownership, so the writing role generally cannot drop
74
+ # what the gem created. Only the engine call — the pool removal and shard
75
+ # deregistration below are local bookkeeping and need no role.
76
+ MigrationRole.wrap { drop_tenant(tenant) }
73
77
  removed_pools = Apartment.pool_manager&.remove_tenant(tenant) || []
74
78
  removed_pools.each do |pool_key, pool|
75
79
  # remove_tenant already took these out of the manager, so deregister_shard's
@@ -148,6 +152,29 @@ module Apartment
148
152
  !tenant_container_exists?(tenant)
149
153
  end
150
154
 
155
+ # The statements Privileges.standard should execute for ctx.phase, or [] when
156
+ # this engine needs none in that phase. A pure function of its inputs: build,
157
+ # do not execute, so the SQL is unit-testable without a database.
158
+ #
159
+ # ConfigurationError rather than NotImplementedError. An adopter who configured
160
+ # the standard policy on a strategy that has none made a configuration mistake,
161
+ # and NotImplementedError descends from ScriptError, so `rescue StandardError`
162
+ # around Tenant.create would not catch it. The NotImplementedError raises
163
+ # elsewhere in this class mean something different: a subclass owes an
164
+ # implementation.
165
+ def standard_privilege_statements(_ctx, grant_to:, include_functions: true) # rubocop:disable Lint/UnusedMethodArgument
166
+ raise(Apartment::ConfigurationError,
167
+ "Apartment::Privileges.standard does not support #{self.class.name}. " \
168
+ 'Write a tenant_privilege_policy for this strategy; see docs/rbac.md.')
169
+ end
170
+
171
+ # The executing database role, for policies that need to name it explicitly
172
+ # (PostgreSQL's ALTER DEFAULT PRIVILEGES FOR ROLE). nil where the engine has
173
+ # no role system. Token shape differs by engine, so each adapter answers.
174
+ def current_db_role(_connection)
175
+ nil
176
+ end
177
+
151
178
  # The namespace that makes the default tenant's tables reachable from any
152
179
  # tenant connection — a schema (PostgreSQL) or a database (MySQL).
153
180
  # Subclasses must implement when shared_pinned_connection? returns true.
@@ -172,16 +199,29 @@ module Apartment
172
199
  # * overwriting the prefix drops one the app set, silently retargeting
173
200
  # the model at a different table.
174
201
  #
175
- # Each case left the model resolving to the *tenant's* table with no
176
- # error. Reading table_name first lets Rails compute the conventional
202
+ # In each case the model resolves to the *tenant's* table and nothing
203
+ # raises. Reading table_name first lets Rails compute the conventional
177
204
  # name — honouring any prefix, suffix, or nesting the app declared —
178
205
  # before we qualify the result.
179
206
  def qualify_pinned_table_name(klass)
180
207
  # Captured before the mutation below: afterwards there is no way to
181
208
  # tell a descendant's stale memo from a table it declared itself.
182
209
  inheriting = klass.apartment_descendants_inheriting_table_name
210
+ # Evaluated before the mutation, which is what inherits_pinned_table?
211
+ # inspects.
212
+ mutates = pinned_qualification_mutates?(klass)
183
213
  apply_pinned_qualification(klass)
184
214
  klass.apartment_resync_descendant_table_names!(inheriting)
215
+ verify_pinned_qualification!(klass) if mutates
216
+ end
217
+
218
+ # Whether qualifying +klass+ will actually change its naming. False for
219
+ # the branch that deliberately mutates nothing, which must not be
220
+ # verified: a subclass sharing an already-pinned base's table is correct
221
+ # by construction, and if its base has not been qualified yet (registry
222
+ # order) it is about to become so.
223
+ def pinned_qualification_mutates?(klass)
224
+ klass.abstract_class? || !inherits_pinned_table?(klass)
185
225
  end
186
226
 
187
227
  def apply_pinned_qualification(klass)
@@ -196,6 +236,118 @@ module Apartment
196
236
  klass.apartment_mark_processed!(path, (original if path == :explicit))
197
237
  end
198
238
 
239
+ # Prove the qualification took effect rather than assuming it did.
240
+ #
241
+ # A failed qualification is otherwise indistinguishable from a successful
242
+ # one: the model is marked processed, nothing raises, and it serves the
243
+ # current tenant's rows. Checking the post-condition surfaces that class
244
+ # of failure at the point it happens — including one introduced by a
245
+ # future Rails change to the naming internals, since compute_table_name
246
+ # is not public API.
247
+ #
248
+ # An abstract base has no table of its own, so it is proven through the
249
+ # descendants its prefix was meant to reach; a `nil` name is skipped.
250
+ #
251
+ # The descendant set is computed here rather than reused from the resync
252
+ # pass: that one is deliberately limited to descendants holding a memo,
253
+ # while a descendant with no memo inherits its name lazily and can be
254
+ # just as wrong. Descendants that declare their own table are excluded —
255
+ # an ancestor's qualification was never meant to reach them, and
256
+ # warn_unregistered_pinned_subclasses already reports that shape.
257
+ # The model itself RAISES; its descendants only WARN. The asymmetry is
258
+ # the point, and it is the same rule warn_unregistered_pinned_subclasses
259
+ # follows: raise only on what this pass can actually prove.
260
+ #
261
+ # For the registered model, the check is complete and unambiguous — it
262
+ # was just qualified, so if the name is not qualified something is
263
+ # genuinely broken, every time, on every boot.
264
+ #
265
+ # For descendants it is neither. Detection walks `descendants`, so it is
266
+ # complete under eager loading and partial under Zeitwerk lazy loading:
267
+ # raising would fail production boots for a condition that dev never
268
+ # reports, while still missing the descendants that load later. A warning
269
+ # carries the same signal without making detection completeness a
270
+ # boot-time dependency.
271
+ def verify_pinned_qualification!(klass)
272
+ prefix = "#{verified_pinned_qualifier}."
273
+
274
+ name = pinned_table_name_for(klass)
275
+ if name && !name.start_with?(prefix)
276
+ raise(Apartment::ConfigurationError, unqualified_pinned_message(klass, name, prefix))
277
+ end
278
+
279
+ warn_unqualified_descendants(klass, prefix)
280
+ end
281
+
282
+ def warn_unqualified_descendants(klass, prefix)
283
+ inheriting_descendants(klass).each do |sub|
284
+ sub_name = pinned_table_name_for(sub)
285
+ next if sub_name.nil? || sub_name.start_with?(prefix)
286
+
287
+ warn(unqualified_pinned_message(sub, sub_name, prefix))
288
+ end
289
+ end
290
+
291
+ # A model whose table_name raises cannot be verified. Say so rather than
292
+ # skipping silently: the thesis of this check is proving rather than
293
+ # assuming, and an unverifiable model is exactly what it must not wave
294
+ # through unremarked.
295
+ def pinned_table_name_for(model)
296
+ model.table_name
297
+ rescue StandardError => e
298
+ warn '[Apartment] could not verify the pinned table name for ' \
299
+ "#{model.name || model.inspect}: #{e.class}: #{e.message}"
300
+ nil
301
+ end
302
+
303
+ # A nil or empty qualifier produces a bare ".table", which start_with?(".")
304
+ # would happily accept — the check proving nothing in exactly the case it
305
+ # exists for. On MySQL this is a connection config with no 'database' key.
306
+ def verified_pinned_qualifier
307
+ qualifier = pinned_table_qualifier
308
+ return qualifier unless qualifier.nil? || qualifier.to_s.empty?
309
+
310
+ raise(Apartment::ConfigurationError,
311
+ "[Apartment] #{self.class}#pinned_table_qualifier is #{qualifier.inspect}, so pinned " \
312
+ 'models were qualified to a bare ".table" and would not resolve. On MySQL this ' \
313
+ "usually means the connection config carries no 'database' key.")
314
+ end
315
+
316
+ def inheriting_descendants(klass)
317
+ return [] unless klass.respond_to?(:descendants)
318
+
319
+ klass.descendants.select do |sub|
320
+ sub.respond_to?(:apartment_inherited_table_name) &&
321
+ !awaiting_own_qualification?(sub) &&
322
+ sub.table_name == sub.apartment_inherited_table_name
323
+ rescue StandardError => e
324
+ warn "[Apartment] could not classify pinned descendant #{sub.name || sub.inspect}: " \
325
+ "#{e.class}: #{e.message}"
326
+ false
327
+ end
328
+ end
329
+
330
+ # A descendant that is registered but not yet processed gets qualified on
331
+ # its own turn, by direct assignment, which reaches names the ancestor's
332
+ # broadcast cannot. Registry order is always parent-first — defining a
333
+ # subclass loads its parent, and pin_tenant registers during class-body
334
+ # execution — so verifying the base's descendants would otherwise raise
335
+ # on a model that is about to become correct, aborting the very iteration
336
+ # that would have fixed it. That is the remedy this check's own error
337
+ # message prescribes, so it has to keep working.
338
+ def awaiting_own_qualification?(klass)
339
+ Apartment.pinned_models.include?(klass) && !klass.apartment_pinned_processed?
340
+ end
341
+
342
+ def unqualified_pinned_message(model, name, prefix)
343
+ "[Apartment] #{model.name || model.inspect} is pinned but its table name " \
344
+ "(#{name.inspect}) is not qualified with #{prefix.inspect}, so it would read the " \
345
+ 'current tenant instead of the default tenant. This usually means Rails composed ' \
346
+ "the name from something the qualifier cannot reach — a module parent's " \
347
+ 'table_name_prefix, or a base class outside the pinned hierarchy. Call pin_tenant ' \
348
+ 'on the model directly, or set self.table_name to an already-qualified name.'
349
+ end
350
+
199
351
  # An abstract class has no table of its own — table_name is nil — so
200
352
  # there is nothing to assign. Pinning one is a supported pattern (an
201
353
  # abstract `connects_to` base is pinned so Apartment does not build
@@ -452,21 +604,151 @@ module Apartment
452
604
  error.is_a?(Apartment::ApartmentError) && error.cause ? error.cause : error
453
605
  end
454
606
 
455
- def grant_tenant_privileges(tenant)
456
- app_role = Apartment.config.app_role
457
- return unless app_role
458
-
459
- conn = ActiveRecord::Base.connection
460
- if app_role.respond_to?(:call)
461
- app_role.call(tenant, conn)
462
- else
463
- grant_privileges(tenant, conn, app_role)
607
+ # Every DDL step of a create runs on config.ddl_role when one is set: the
608
+ # container, both privilege-policy phases, and any schema import.
609
+ #
610
+ # PostgreSQL scopes an ALTER DEFAULT PRIVILEGES rule with no FOR ROLE to the
611
+ # role that EXECUTES it, never to the role named in the GRANT. Recording such a
612
+ # rule under one role while migrations create tables under another leaves every
613
+ # migration-created table outside it — the same missing-grant failure as running
614
+ # the migrations themselves on the writing role, only it surfaces later, from
615
+ # whatever ordinary query first touches the new table.
616
+ #
617
+ # Two further constraints keep the steps in ONE wrap rather than wrapping only
618
+ # the policy. The container is owned by whoever created it, and ALTER on a table
619
+ # needs ownership. And a policy that hands the app role USAGE plus DML, never
620
+ # CREATE, leaves a schema import on the writing role unable to add tables to a
621
+ # container it does not own.
622
+ #
623
+ # Seeding stays outside the wrap: it writes rows, and rows carry no ownership.
624
+ def run_tenant_ddl(tenant)
625
+ MigrationRole.wrap do
626
+ create_tenant(tenant)
627
+ db_role = resolve_privilege_db_role
628
+ apply_privilege_policy(tenant, :before_schema_load, db_role)
629
+ import_schema(tenant) if Apartment.config.schema_load_strategy
630
+ apply_privilege_policy(tenant, :after_schema_load, db_role)
464
631
  end
632
+ ensure
633
+ discard_ddl_role_pool(tenant)
465
634
  end
466
635
 
467
- # No-op base implementation PG schema and MySQL adapters override.
468
- def grant_privileges(tenant, connection, role_name)
469
- # intentional no-op
636
+ # import_schema switches into the new tenant, and a pool key carries the role it
637
+ # was resolved under (Patches::ConnectionHandling), so that switch registers a
638
+ # DDL-role pool nothing else will claim.
639
+ #
640
+ # Two narrowings, both about not disconnecting somebody else's work. This
641
+ # discards a single key rather than calling PoolManager#evict_by_role, which
642
+ # Migrator uses at the end of a run and which would also drop a pool another
643
+ # thread is migrating through. And it skips a pool that is still in use, since
644
+ # the SAME key is what a concurrent migration of this tenant leases: creating a
645
+ # tenant that a Migrator run is already covering would otherwise pull the pool
646
+ # out from under it. What lingers instead is one idle pool, which the reaper
647
+ # collects on its own terms.
648
+ #
649
+ # The release comes first because our own import_schema lease is on this pool;
650
+ # without it the in-use check would see this thread and skip every discard.
651
+ #
652
+ # The in-use check is a narrowing, not a lock: another thread can lease between
653
+ # pool_in_use? and deregister_shard. Accepted, and documented with the reasoning
654
+ # at PoolManager#remove — leasing bypasses the manager's map entirely, so the race
655
+ # cannot be closed there.
656
+ def discard_ddl_role_pool(tenant)
657
+ role = Apartment.config.ddl_role
658
+ return unless role
659
+
660
+ pool_key = Apartment.pool_key(tenant, role)
661
+ pool = Apartment.pool_manager&.peek(pool_key)
662
+ release_pool_connection(tenant, pool)
663
+ return if Apartment.pool_in_use?(pool)
664
+
665
+ Apartment.deregister_shard(pool_key)
666
+ end
667
+
668
+ # Releasing a lease must not mask the create's own outcome, and it has thrown
669
+ # before: see Migrator#release_tenant_pool_connection, which learned the same
670
+ # lesson from a ThreadError during teardown.
671
+ def release_pool_connection(tenant, pool)
672
+ pool&.release_connection
673
+ rescue StandardError => e
674
+ warn "[Apartment] Connection release failed for '#{tenant}': #{e.class}: #{e.message}"
675
+ end
676
+
677
+ # Schema import and seeding switch into the tenant being created, and resolving
678
+ # that pool is where ConnectionHandling runs its pending-migration check. The
679
+ # container is seconds old and has of course run no migration, so the check
680
+ # fires against the very thing create is building and create raises instead of
681
+ # finishing. Only reproducible where the check is live — check_pending_migrations
682
+ # true (the default) and Rails.env.local? — so development and test, which is
683
+ # where creating a tenant by hand is most common.
684
+ #
685
+ # A switch alone does not trip it; the pool is resolved by the first query. That
686
+ # is why the failure looks intermittent across configurations: it needs a schema
687
+ # import, or seeds that actually touch the database.
688
+ #
689
+ # Migrator suppresses the same check with the same flag around its own switches.
690
+ # The previous value is restored rather than cleared, so a create nested inside a
691
+ # migration — an adopter's :create callback, a create-then-migrate helper — does
692
+ # not disarm the migration's own suppression on the way out.
693
+ #
694
+ # The window covers the whole :create callback chain, deliberately. Provisioning
695
+ # rows in the tenant just created is what those callbacks are for, and with the
696
+ # default schema_load_strategy of nil that tenant has no schema_migrations yet, so
697
+ # a narrower window would leave every such callback raising the very error this
698
+ # method exists to prevent.
699
+ #
700
+ # The cost of that width: Current.migrating is a boolean, not tenant-scoped, so a
701
+ # callback that switches to some OTHER cold tenant also skips that tenant's check
702
+ # and leaves its pool warm and unchecked. Accepted rather than overlooked. The
703
+ # check is a development convenience (config.check_pending_migrations plus
704
+ # Rails.env.local?), the effect is one missed warning until that pool is evicted,
705
+ # and closing it properly means making the flag tenant-aware — which Migrator also
706
+ # sets, per worker, so it is a change to a shared contract and belongs on its own.
707
+ def suppressing_pending_migration_check
708
+ previous = Apartment::Current.migrating
709
+ Apartment::Current.migrating = true
710
+ yield
711
+ ensure
712
+ Apartment::Current.migrating = previous
713
+ end
714
+
715
+ # The database role both phases report, resolved once per create inside the
716
+ # ddl_role wrap so the round trip is paid once. nil when no policy is
717
+ # configured: an adopter without one should pay nothing at all.
718
+ #
719
+ # Deliberately a local in run_tenant_ddl rather than an ivar. Apartment.adapter
720
+ # is one instance for the life of the process, so an adapter ivar is shared
721
+ # across concurrent creates — thread B could read the role thread A resolved and
722
+ # name it in ALTER DEFAULT PRIVILEGES FOR ROLE while creating objects as its
723
+ # own. That is the bug this design exists to prevent, arriving through shared
724
+ # state, and it is invisible whenever both creates share a ddl_role.
725
+ def resolve_privilege_db_role
726
+ return unless Apartment.config.tenant_privilege_policy
727
+
728
+ current_db_role(ActiveRecord::Base.connection)
729
+ end
730
+
731
+ # Invoke the adopter's policy for one phase. Two calls per create, because
732
+ # position is policy: a default-privileges-only model has to record its rules
733
+ # before the schema import or imported tables fall outside them, while a model
734
+ # granting existing objects has to run after. See
735
+ # docs/designs/v4-rbac-contract.md.
736
+ #
737
+ # A fresh context per phase; the two share nothing but the resolved role, which
738
+ # arrives as an argument.
739
+ def apply_privilege_policy(tenant, phase, db_role)
740
+ policy = Apartment.config.tenant_privilege_policy
741
+ return unless policy
742
+
743
+ policy.call(
744
+ Privileges::Context.new(
745
+ tenant: tenant,
746
+ container_name: physical_tenant_name(tenant),
747
+ connection: ActiveRecord::Base.connection,
748
+ db_role: db_role,
749
+ phase: phase
750
+ )
751
+ )
470
752
  end
471
753
 
472
754
  # Connection config with string keys (used by subclasses to build tenant configs).
@@ -35,6 +35,43 @@ module Apartment
35
35
  [ActiveRecord::NoDatabaseError, Apartment::ApartmentError]
36
36
  end
37
37
 
38
+ # MySQL has no ALTER DEFAULT PRIVILEGES. `ON db.*` is pattern-based and covers
39
+ # objects created later, so one statement in the first phase is the whole
40
+ # policy and include_functions has nothing to control here.
41
+ #
42
+ # grant_to takes bare role names and every grant lands on `role@'%'`. Splitting
43
+ # an account on its last @ would be wrong, because `me@localhost` is itself a
44
+ # legal MySQL username, so a value carrying @ is refused rather than guessed at.
45
+ # A specific host is what a custom policy is for.
46
+ #
47
+ # Branching on the phase by name, rather than falling out of a
48
+ # before_schema_load? guard, so the empty after-phase is a stated decision and an
49
+ # unrecognised phase raises. A silent nothing is the defect this whole design
50
+ # replaces: app_role's String form did nothing on two adapters and told nobody.
51
+ def standard_privilege_statements(ctx, grant_to:, include_functions: true) # rubocop:disable Lint/UnusedMethodArgument
52
+ case ctx.phase
53
+ when :before_schema_load
54
+ roles = Array(grant_to)
55
+ validate_bare_role_names!(roles)
56
+ accounts = roles.map { |role| "#{ctx.connection.quote(role)}@'%'" }.join(', ')
57
+ ["GRANT SELECT, INSERT, UPDATE, DELETE ON #{ctx.quoted_container}.* TO #{accounts}"]
58
+ when :after_schema_load
59
+ # Nothing to do: the grant above already covers tables the import and later
60
+ # migrations create.
61
+ []
62
+ else
63
+ raise(Apartment::ConfigurationError, "Unknown privilege policy phase: #{ctx.phase.inspect}")
64
+ end
65
+ end
66
+
67
+ # Returns MySQL's `role@host` form, for a policy that needs to name the
68
+ # executing account. The statement builder above does not consume it: it assumes
69
+ # the `%` host, and MySQL's GRANT syntax wants the halves quoted separately
70
+ # (`'role'@'host'`), which a whole `role@host` token cannot express.
71
+ def current_db_role(connection)
72
+ connection.select_value('SELECT CURRENT_USER()')
73
+ end
74
+
38
75
  protected
39
76
 
40
77
  def create_tenant(tenant)
@@ -51,12 +88,13 @@ module Apartment
51
88
 
52
89
  private
53
90
 
54
- def grant_privileges(tenant, connection, role_name)
55
- db_name = environmentify(tenant)
56
- quoted_role = connection.quote(role_name)
57
- connection.execute(
58
- "GRANT SELECT, INSERT, UPDATE, DELETE ON #{connection.quote_table_name(db_name)}.* TO #{quoted_role}@'%'"
59
- )
91
+ def validate_bare_role_names!(roles)
92
+ hosted = roles.grep(/@/)
93
+ return if hosted.empty?
94
+
95
+ raise(Apartment::ConfigurationError,
96
+ "Apartment::Privileges.standard takes bare role names and grants to role@'%'. " \
97
+ "Got: #{hosted.inspect}. Write a tenant_privilege_policy to grant to another host.")
60
98
  end
61
99
 
62
100
  def container_error?(error)
@@ -27,6 +27,17 @@ module Apartment
27
27
  [ActiveRecord::NoDatabaseError, Apartment::ApartmentError]
28
28
  end
29
29
 
30
+ # The engine is the same as the schema strategy's, so the token is identical.
31
+ # Implemented rather than inherited because this is the strategy most likely to
32
+ # need it: standard_privilege_statements raises here (see below), so privileges
33
+ # come from a hand-written policy, and that policy is exactly the code that wants
34
+ # ALTER DEFAULT PRIVILEGES FOR ROLE. Inheriting the base nil would leave it
35
+ # resolving the grantor itself, which is correct-by-position — the coupling this
36
+ # design removes.
37
+ def current_db_role(connection)
38
+ connection.select_value('SELECT current_user')
39
+ end
40
+
30
41
  protected
31
42
 
32
43
  def create_tenant(tenant)
@@ -49,11 +60,12 @@ module Apartment
49
60
  )
50
61
  end
51
62
 
52
- # grant_privileges: inherits no-op from AbstractAdapter.
53
- # Database-per-tenant RBAC grants require cross-database ordering
54
- # (GRANT CONNECT on server, table grants inside tenant DB).
55
- # Use the callable app_role escape hatch for this strategy.
56
- # See docs/designs/v4-phase5-rbac-roles-schema-cache.md.
63
+ # standard_privilege_statements: inherits the ConfigurationError raise from
64
+ # AbstractAdapter. Database-per-tenant RBAC grants require cross-database
65
+ # ordering (GRANT CONNECT on the server, table grants inside the tenant DB),
66
+ # which Privileges.standard does not implement. Write a
67
+ # tenant_privilege_policy for this strategy instead.
68
+ # See docs/designs/v4-rbac-contract.md.
57
69
 
58
70
  private
59
71