ec-pg

Multi-tenancy for Rails + PostgreSQL. Supports three isolation strategies — schema-per-tenant, database sharding, and row-level security (RLS) — with thread-safe context management and optional Rack middleware for automatic per-request switching.

Requirements

  • Ruby >= 3.2.0
  • Rails / ActiveRecord >= 7.1
  • PostgreSQL adapter (pg)

Installation

gem 'ec-pg', github: 'binnablus/ec-pg'

Strategies

Strategy Isolation level Best for
Schema-per-tenant PostgreSQL schema (search_path) Strong isolation, moderate tenant count
Sharding ActiveRecord multi-database Horizontal scale, data locality
Row-Level Security PostgreSQL RLS policies Lightweight isolation in a shared schema

The three strategies can be used independently or combined.


Configuration

# config/initializers/ec_pg.rb
Ec::Pg.configure do |c|
  c.default_schema    = 'public'        # schema when none is active
  c.shared_schemas    = ['public']      # always appended to search_path
  c.number_of_shards  = 4              # total shards in the cluster
  c.rls_mode          = :local         # :local (per-transaction) or :session

  # Required when using the ContextSwitcher middleware.
  # Return a hash with :shard and/or :schema keys.
  c.get_context_method = ->(request) {
    subdomain = request.host.split('.').first
    { schema: subdomain }
  }

  # Paths that bypass context switching (substring match)
  c.context_switch_exclude_paths = ['health', 'status', 'metrics']

  c.logger = Logger.new($stdout)
end

Schema-per-tenant

Each tenant lives in its own PostgreSQL schema. The gem sets search_path for the duration of a block and restores it automatically.

Model setup

class ApplicationRecord < ActiveRecord::Base
  include Ec::Pg::SchemaMixin
  acts_as_namespaced
end

Usage

# Block form — preferred
Ec::Pg::SchemaManager.with_schema('acme') do
  User.all   # queries acme.users
end

# Stateful form
Ec::Pg::SchemaManager.apply!('acme')
User.all
Ec::Pg::SchemaManager.reset!

# Inspect current schema
Ec::Pg::SchemaManager.current_schema  # => 'acme'
Ec::Pg::SchemaManager.active?         # => true

Schema names are validated (alphanumeric + underscores only) to prevent SQL injection.


Sharding

Wraps ActiveRecord's connected_to to route queries to the right shard.

database.yml

production:
  primary: &primary
    adapter: postgresql
    # ...
  shard_one:
    <<: *primary
    database: app_shard_one
  shard_two:
    <<: *primary
    database: app_shard_two

Model setup

class ApplicationRecord < ActiveRecord::Base
  include Ec::Pg::ShardMixin

  # :solo — multiple shards pointing to the same DB (dev/test)
  # :sharded — separate database per shard
  acts_as_sharded(
    mode: :sharded,
    writing_database_identifier: :shard_one,
    reading_database_identifier: :shard_one_replica   # optional
  )
end

Usage

Ec::Pg::ShardManager.with_shard(:shard_one) do
  User.all   # routed to shard_one
end

Ec::Pg::ShardManager.with_shard(:shard_two, role: :reading) do
  Report.all
end

Ec::Pg::ShardManager.current_shard   # => :shard_one (or :default)

Row-Level Security

Sets PostgreSQL session variables that your RLS policies can read (e.g. current_setting('app.tenant_id')).

PostgreSQL policy example

CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

Model setup

class ApplicationRecord < ActiveRecord::Base
  include Ec::Pg::RlsMixin

  acts_as_rls(
    mode: :local,    # variables reset when transaction ends
    variables: {
      tenant_id: 'app.tenant_id',
      user_id:   'app.user_id'
    }
  )
end

Usage

User.with_rls(variables: { tenant_id: 'acme-uuid', user_id: 42 }) do
  User.all   # policy restricts to acme rows
end

Modes

Mode Variable lifetime
:local (default) Reset at transaction end
:session Persist until explicitly cleared

Combining strategies

Use Ec::Pg.switch to set both shard and schema in one call:

Ec::Pg.switch(shard: :shard_two, schema: 'acme') do
  User.all
end

# Inspect
Ec::Pg.current_shard   # => :shard_two
Ec::Pg.current_schema  # => 'acme'

Rack middleware

Add ContextSwitcher to automatically resolve tenant context from each request:

# config/application.rb
config.middleware.use Ec::Pg::Middleware::ContextSwitcher

get_context_method (configured above) is called on every request. If it raises, the middleware returns 422 Unprocessable Entity with a JSON error body. Paths matching any entry in context_switch_exclude_paths bypass the switcher entirely.


Thread safety

Context is stored in thread-local variables. Each thread/fiber has its own isolated context, and block forms (with_schema, with_shard, with_rls) always restore the previous context — even when an exception is raised.


Low-level context API

# Read
Ec::Pg::Context.shard    # => :shard_one or nil
Ec::Pg::Context.schema   # => 'acme' or nil
Ec::Pg::Context.current  # => { shard: :shard_one, schema: 'acme' }
Ec::Pg::Context.active?  # => true if any key is set

# Write
Ec::Pg::Context.set(shard: :shard_one, schema: 'acme')
Ec::Pg::Context.delete(:shard)
Ec::Pg::Context.clear!

# Temporary override (block-scoped)
Ec::Pg::Context.with(schema: 'other') { ... }

Development

bin/setup       # install dependencies
rake spec       # run test suite
bin/console     # interactive prompt

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/binnablus/ec-pg. Contributors are expected to follow the code of conduct.

License

MIT — see LICENSE.txt.