Application-layer validation is the weakest layer in the system. It must never be the wall. The database itself is the wall.
why App code can forget a WHERE tenant_id =. A least-privilege role under forced row-level
security cannot. The boundary cannot depend on the next engineer remembering an invariant, so the
invariant has to live where they cannot route around it. A forgotten filter falls through to RLS, a
missed validation falls through to the read-only role.
what we tried, and what broke The tempting design makes the query validator the boundary: parse
the SQL, blocklist the dangerous keywords, ship it. One regex gap is then a breach. A validator that
checks DELETE as a substring also false-positives on WHERE name = 'DELETE'.
what we chose Six independent layers, each bypass-resistant on its own, so a successful exfil has to defeat all six.
- String-aware SQL validation. First keyword must be
SELECTorWITH; a dangerous-keyword blocklist matched against a literal-and-comment-stripped copy (soWHERE name='DELETE'is not a false positive); embedded semicolons rejected; a balanced-parens check on the model-authored SQL before it is wrapped and limited. The weakest layer on its own, but it catches known patterns and returns clear errors the model can self-correct against. - Read-only transaction.
BeginTx(ctx, &sql.TxOptions{ReadOnly: true}). Postgres rejects writes at the engine even if validation missed one. - Least-privilege role.
CREATE ROLE ... WITH LOGIN NOINHERIT(NOINHERIT blocks privilege creep) plusGRANT SELECTon a fixed allowlist of tables only. FORCE ROW LEVEL SECURITYwith a policy readingcurrent_setting('app.tenant_id', true).- Transaction-scoped tenant context.
SELECT set_config('app.tenant_id', $1, true)(thetrueis is_local, so the value dies at transaction end). Fail-closed: if it is never set,current_settingreturns NULL,tenant_id = NULLmatches zero rows. The default is no data, not all data. - Parameterized tenant value. Bound via
$1, never string-concatenated, and shape-validated upstream.
01 │ // layer 5: tenant context, scoped to THIS transaction only (is_local = true)
02 │ tx.ExecContext(ctx, "SELECT set_config('app.tenant_id', $1, true)", tenantID)
03 │ // the RLS policy reads current_setting('app.tenant_id', true)
04 │ // if it is never set -> NULL -> tenant_id = NULL matches ZERO rows. the default is no data.01 │ -- the boundary is the database, not the validator
02 │ CREATE ROLE app_ro LOGIN NOINHERIT; -- NOINHERIT blocks privilege creep
03 │ GRANT SELECT ON events, entities TO app_ro; -- read-only, a FIXED allowlist of tables only
04 │
05 │ ALTER TABLE events ENABLE ROW LEVEL SECURITY;
06 │ ALTER TABLE events FORCE ROW LEVEL SECURITY; -- even the table owner cannot bypass
07 │ CREATE POLICY tenant_isolation ON events
08 │ USING (tenant_id = current_setting('app.tenant_id', true));
09 │
10 │ -- the model-authored SQL runs inside the read-only transaction, wrapped and limited:
11 │ SELECT * FROM ( /* model-authored SQL */ ) AS _q LIMIT 1000; -- balanced-parens checked