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 set | what it says |
|---|---|
off | this database separates nothing — and it does, by user, on every table |
heuristic with user_id | every one of those tables is a gap until RLS exists — a plan, not the state |
listed with an empty list | nobody 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.
Related
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.