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 +4 -4
- data/README.md +13 -2
- data/lib/apartment/CLAUDE.md +19 -1
- data/lib/apartment/adapters/CLAUDE.md +13 -0
- data/lib/apartment/adapters/abstract_adapter.rb +303 -21
- data/lib/apartment/adapters/mysql2_adapter.rb +44 -6
- data/lib/apartment/adapters/postgresql_database_adapter.rb +17 -5
- data/lib/apartment/adapters/postgresql_schema_adapter.rb +45 -23
- data/lib/apartment/cli/migrations.rb +1 -1
- data/lib/apartment/concerns/model.rb +14 -8
- data/lib/apartment/config.rb +9 -8
- data/lib/apartment/migration_role.rb +97 -0
- data/lib/apartment/migrator.rb +6 -7
- data/lib/apartment/patches/connection_handling.rb +147 -90
- data/lib/apartment/pool_manager.rb +18 -0
- data/lib/apartment/pool_reaper.rb +1 -8
- data/lib/apartment/privileges/context.rb +47 -0
- data/lib/apartment/privileges.rb +46 -0
- data/lib/apartment/version.rb +1 -1
- data/lib/apartment.rb +79 -3
- data/lib/generators/apartment/install/templates/apartment.rb +2 -2
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8e5931794be153df0c2443c215f142fa9f07c8e0a0830f5efac616e0c306e58
|
|
4
|
+
data.tar.gz: e97bbef33052e8bae6a0ce036afc61aeb19a9b1a04895ddcdfb138b5bf89f031
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
data/lib/apartment/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
176
|
-
#
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
#
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
#
|
|
53
|
-
# Database-per-tenant RBAC grants require cross-database
|
|
54
|
-
# (GRANT CONNECT on server, table grants inside tenant DB)
|
|
55
|
-
#
|
|
56
|
-
#
|
|
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
|
|