Row-level security
Every organization-owned query in a RailsFast app is scoped in Ruby: current_organization.projects. One forgotten scope, one Project.find(params[:id]), and a customer reads another customer's rows. PostgreSQL row-level security puts the same rule in the database: a table with policies shows and accepts only the rows of the organization the database session names, whatever the query says.
RailsFast ships it opt-in, since v1.8.0. Off, nothing changes. The template's docs/row-level-security.md is the full reference.
Turn it on
-
In
config/railsfast/railsfast.yml, setrow_level_security: true. -
Give tables policies in a migration:
class CreateProjects < ActiveRecord::Migration[8.1] include RowLevelSecurity::Migration def change create_tenant_table :projects do |t| t.string :name, null: false end create_tenant_table :tasks do |t| t.uuid :project_id, null: false end add_tenant_foreign_key :tasks, :project_id, :projects enable_row_level_security :projects enable_row_level_security :tasks end end -
Connect the app to PostgreSQL as a role that is neither a superuser nor
BYPASSRLS. PostgreSQL never holds those to policies, and a stock Kamal deploy connects as thepostgressuperuser. Until you switch, the app logs a warning at boot in production.
enable_row_level_security works on an existing table too: any table with an organization_id column. It takes a brief lock and rewrites nothing.
What the helpers do
create_tenant_tableaddsorganization_id(required, a foreign key to the organizations table), timestamps, and a unique index on(organization_id, id).add_tenant_foreign_keymakes the reference composite:(organization_id, project_id)points at(organization_id, id), so PostgreSQL refuses a task that points at another organization's project.enable_row_level_securityenables and forces row-level security with two policies.tenantcovers reads and writes: the row's organization is the session's.controlcovers reads only (below). It is reversible.
Where the organization comes from
- Requests:
current_organization, resolved before every action. - Jobs, scripts, the console:
Organizations.with_organization(organization) { ... }. Without it a job sees no rows of those tables and can write none. - Rails resets it before every request and every job, so a pooled database connection never carries one organization into the next.
Before each statement, the adapter brings the session setting in line with the current organization, in one round trip, and skips it when the connection already holds that value. It forgets what it applied after every transaction completion, raw SQL statement and reconnect. When the organization changes inside a transaction, a rollback could restore the earlier one, so until that transaction ends the setting is applied before every statement, and SQL with a ; followed by more text is refused (RowLevelSecurity::BatchRefused): send statements one by one. Each switch, and each reset, clears the query cache.
Reading across organizations
A job that enumerates every organization, or an admin screen, reads under control:
due = RowLevelSecurity.with_control(reason: "nightly invoice run") { Invoice.due.to_a }
due.each { |invoice| Organizations.with_organization(invoice.organization) { invoice.charge! } }
The block reads every organization's rows and writes none, even when entered from inside an organization. Each entry is logged with its reason and its caller.
Limits
The database does not scope code that uses raw_connection directly, SQL that changes the setting itself, or tables without policies: keep scoping those in Ruby. With row-level security on, the app refuses to boot while config.active_record.async_query_executor is set, because a load_async query may run under another organization: remove the executor (without one, load_async runs at once).