ros-apartment 4.0.0.alpha11 → 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: ad60ba3db98c859ff52e1aa0f312d18a8a3e99a6b2703518a94f1ab4abc95eb5
4
- data.tar.gz: a782d5dc9690202676d2e30529da89aeb226961bffaf9af7fc821292266d97c6
3
+ metadata.gz: b8e5931794be153df0c2443c215f142fa9f07c8e0a0830f5efac616e0c306e58
4
+ data.tar.gz: e97bbef33052e8bae6a0ce036afc61aeb19a9b1a04895ddcdfb138b5bf89f031
5
5
  SHA512:
6
- metadata.gz: 18aaad1312c4eb812c1a7f4a1bcd676325a90db1326efcd2bb4aa71961f38e689240a589497ae6fa7bb16f362276649b36610d76b4b63284567d1999a4a20b5f
7
- data.tar.gz: 2f445f6e7d07ef0c4146242aadbb724636fa55184e4c16400daf5870e84a46a6566530cca0a10e130813709c27a08dfed1ba8b6eb4589ab97087b37966ce0b24
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,9 +101,15 @@ 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 `self.table_name` was explicitly set vs convention (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**.
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
- **Lifecycle:** `apartment_pinned_processed?`, `apartment_mark_processed!`, `apartment_restore!` — qualification state and teardown. 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).
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).
107
+
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`.
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
+
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).
103
113
 
104
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.
105
115
 
@@ -122,6 +132,18 @@ Three hooks in Rails boot order:
122
132
 
123
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.
124
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
+
125
147
  ### schema_dumper_patch.rb — Rails 8.1 Schema Fix
126
148
 
127
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`.