Skip to main content

SEC.RLS.DISABLED — A tenant table without row-level security

  • Category: security
  • Severity: high
  • Level: 0
  • Confidence: deterministic
  • Downtime class: none — the finding is about a table's protection, not about a statement
  • Stability: stable
  • Suites: audit
  • Applies to: PostgreSQL 18

The separation is in your code and nowhere in your database

You told SQLens this table holds tenant data. Row-level security is not enabled on it, so every row is visible to any role holding SELECT — the tenant boundary exists only in the WHERE clause your application writes, and nothing below it enforces the boundary. One query without that clause returns the whole table, to whoever asked, with no error anywhere.

That is a different situation from a table nobody claimed was tenant-scoped. This finding only ever appears for tables you named.

SQLens does not guess which tables those are

There is no naming convention it trusts and no column it assumes. Guessing would report every reference table, job queue and migration ledger in your schema — a report nobody finishes reading.

So you say it, in config/sqlens.php:

'security' => [
'rls' => [
// 'listed' (default) — you name the tables
// 'heuristic' — every table carrying `tenant_column`
// 'off' — this database separates nothing
// 'application' — it separates, in your code; `reason` says how
'mode' => 'listed',
'tables' => ['orders', 'invoices', 'reporting.orders_archive'],
'tenant_column' => 'tenant_id',
'reason' => null,
],
],

A name may be written either way. orders resolves through current_schema() — the same place an unqualified CREATE TABLE puts a table, so the name your migration used is the name that works here. Qualify it (reporting.orders_archive) to reach a table in another schema. A name that resolves to nothing is reported as a skip that says which of the two happened, because a list matching nothing reports nothing and otherwise looks configured.

Until you answer, SQLens reports one undetermined naming those keys, on every audit run. It is deliberately not silent: a security suite that says nothing about row-level security on a database that has none is indistinguishable from one that checked and found everything in order, and those two reports must never look the same.

off is an answer, and it reads like one

Setting mode to off says this database separates nothing — and the report then carries a not_applicable finding saying exactly that, rather than the undetermined a project that never looked would get. The distinction is the point: a question you answered must not produce the same line as one you never read, or the line becomes backlog nobody closes.

It also means the run can go green. undetermined escalates under strict_undetermined, which --profile=ci sets; not_applicable does not, so a project whose tenant separation lives in the query layer no longer has to waive every undetermined at once to get a clean pipeline.

The checks come back the moment the answer changes: name a table, or switch the mode to heuristic.

application is the other answer, and it is not off

Most applications that keep their users' data apart do it in the application: a policy, a global scope, a visibleTo() on every query. The database holds a user_id and enforces nothing. Three modes had no word for that, and each of the alternatives says something false:

what you would have setwhat it says
offthis database separates nothing — and it does, by user, on every table
heuristic with user_idevery one of those tables is a gap until RLS exists — a plan, not the state
listed with an empty listnobody has answered — the undetermined you started with

So say it:

'rls' => [
'mode' => 'application',
'reason' => 'every row belongs to a user; policies and visibleTo() scopes enforce it on every query.',
],

The sentence is required — the config is refused without it — and it travels into the report, beside a not_applicable finding. That is the whole difference between this and switching a check off: whoever reads the audit next sees what enforces the separation and where it lives, rather than an absence.

Like off, it does not escalate under strict_undetermined, and like off the checks come back the moment separation moves into the database.

Bad

CREATE TABLE orders (id bigserial PRIMARY KEY, tenant_id uuid NOT NULL, total numeric);
-- listed in sqlens.security.rls.tables, and readable in full by every role with SELECT

Good

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- ENABLE does not apply to the table's OWNER, and a Laravel application usually connects as the
-- role that owns its tables. Without FORCE, the protection is off for exactly that connection.
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

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

Set app.tenant per request — for example in a middleware — and the database enforces what your WHERE clause was doing on its own.

An account with BYPASSRLS, and any superuser, reads every row whatever your policies say. When SQLens sees one, this finding names it and points at SEC.PRIV.ROLE_BYPASSRLS — enabling row-level security here does not restrict that account, so the two have to be fixed together.