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
'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.

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.