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
'mode' => 'listed',
'tables' => ['public.orders', 'public.invoices'],
'tenant_column' => 'tenant_id',
],
],
Until you do, 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.
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.