PostgreSQL Native Row-Level Security
RowLevelSecurityNative delegates tenant isolation to PostgreSQL's
built-in row-level security. Each query runs in a transaction that
first calls set_config('app.tenant_id', <tenant>, true), and the
CREATE POLICY clauses installed on tenant-scoped tables reference
that session variable to filter rows. Quark cannot bypass the policy
even from client.Raw() — the engine itself enforces isolation.
Native is PostgreSQL-only. Other dialects continue to use
RowLevelSecurityClient, which
is client-side WHERE injection on the Quark builder.
See ADR-0012 for the design rationale and the trade-offs against the legacy client-side strategy.
When to use Native vs Client
| Concern | RowLevelSecurityNative | RowLevelSecurityClient |
|---|---|---|
| Where the filter is enforced | PostgreSQL engine | Quark query builder |
client.Raw() / client.Exec() | Filtered by the policy | Bypasses the predicate |
| Dialects supported | PostgreSQL only | All six dialects |
| Setup cost | One CREATE POLICY per table | None |
| Per-query overhead | Implicit transaction + set_config | None |
| Recommended for | Production where bypass risk matters | Cross-dialect dev / staging |
Native is not a drop-in upgrade of Client. The two are mutually exclusive per router. Pick Native when you want the engine to be the last line of defence and you can install policies; pick Client when portability across the six dialects matters more.
Setup
1. Install the policy on each tenant-scoped table
You have two options: run the DDL yourself, or embed the
quarktenant library and run it from CI.
Option A — quarktenant CLI (recommended): embed the library in
a small main.go that registers your models on the Client and
delegates to quarktenant.Run. The library reads every registered
model and emits one ALTER TABLE ... ENABLE/FORCE ROW LEVEL SECURITY
pair plus one CREATE POLICY <table>_tenant_isolation per table.
// myapp/cmd/tenant/main.go
package main
import (
"context"
"os"
"github.com/jcsvwinston/quark"
"github.com/jcsvwinston/quark/quarktenant"
_ "github.com/jackc/pgx/v5/stdlib"
"myapp/models"
)
func main() {
client, _ := quark.New("pgx", os.Getenv("QUARK_DSN"),
quark.WithLimits(quark.Limits{AllowRawQueries: true}))
defer client.Close()
_ = client.RegisterModel(&models.Order{}, &models.Invoice{})
os.Exit(quarktenant.Run(context.Background(), os.Args[1:], client))
}
Then in CI / Makefile:
# Print the DDL the library would emit (no DB change)
go run ./cmd/tenant install-rls-policies --dry-run
# Apply for real. Acquires a distributed migration lock first.
go run ./cmd/tenant install-rls-policies
# Override the tenant column or session variable
go run ./cmd/tenant install-rls-policies \
--tenant-col=org_id \
--native-rls-var=app.org_id
A complete runnable example lives at
examples/tenant-rls-native/main.go.
Option B — manual DDL: write the SQL yourself and apply it through your normal schema-migration pipeline.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY orders_tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id', true)::text)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::text);
The WITH CHECK clause is what stops inserts and updates from
escaping isolation; without it, a tenant could write rows for another
tenant even though it cannot read them.
ALTER TABLE ... FORCE ROW LEVEL SECURITY is important: by default
CREATE POLICY exempts the table owner. In most deployments the
application role IS the owner, which would silently make the policy
decorative. FORCE removes that exemption so the policy applies to
every role.
current_setting() always returns TEXT. When your tenant column is
TEXT/VARCHAR the default ::text cast just works. When the column is
UUID or BIGINT you must override the cast — pass --cast=uuid to
the CLI, or write the policy DDL manually with ::uuid /
::bigint. Otherwise the comparison fails at runtime and the policy
returns zero rows.
2. Configure the router
cfg := quark.DefaultTenantConfig()
cfg.Strategy = quark.RowLevelSecurityNative
cfg.BaseClient = pgClient
// cfg.NativeRLSVar = "app.tenant_id" // default, override only if your policy uses a different name
router := quark.NewTenantRouter(cfg, ResolveTenant, nil)
NativeRLSVar defaults to "app.tenant_id". If your policies
reference a different setting (e.g., app.org_id), set it here and
keep both sides in sync.
3. Issue queries
Two paths, both transparent:
// Recommended for multi-step work: single transaction, single set_config.
err := router.Tx(tenantCtx, func(tx *quark.Tx) error {
var orders []Order
orders, err := quark.ForTx[Order](tenantCtx, tx).
Where("status", "=", "paid").
List()
if err != nil { return err }
// ... more queries against the same tx ...
return nil
})
// Fire-and-forget single query: implicit transaction per call.
orders, err := quark.For[Order](tenantCtx, router).
Where("status", "=", "paid").
List()
Both paths reach PostgreSQL with current_setting('app.tenant_id', true)
set to the resolved tenant. The policy filters rows server-side.
Implicit-transaction caveat
For[T](ctx, router) under Native wraps every executor call
(ExecContext, QueryContext, QueryRowContext) in an implicit
transaction. This is necessary because set_config(..., is_local=true)
only takes effect within a transaction.
There is one trade-off: *sql.Rows and *sql.Row are opaque struct
types from database/sql, so the implicit transactions are committed
by a context.AfterFunc registered against the caller's
context.Context rather than when the rows close. The practical
consequences:
- Request-scoped
ctx(typical HTTP handler): the tx commits when the handler returns. Connection released cleanly. Use this freely. - Long-lived
ctx(CLI batch jobs running many queries before cancellation): each query holds its connection open untilctxends. With high tenant concurrency this can saturate the connection pool. Userouter.Txfor batch jobs. - Streaming via
Iter/Cursor: same issue, plus the cursor must outlive the tx. Userouter.Txand run the iterator inside the callback.
The recommended pattern is therefore: router.Tx for everything
non-trivial. The implicit-For[T] path is the ergonomic path for
short reads in request-scoped contexts.
Raw queries under Native RLS
client.RawQuery and client.Exec run straight against the connection
pool — they do not go through For[T], so they never open the
implicit transaction that issues set_config('app.tenant_id', …).
Unlike RowLevelSecurityClient (where raw SQL silently escapes the
client-side WHERE tenant_id = ? predicate), this is not a security
hole under Native: a SELECT with no app.tenant_id set returns zero
rows because the policy's USING clause evaluates against an empty
variable, and an INSERT fails the policy's WITH CHECK. The engine
stays in control either way.
It is, however, almost never what you want: you lose the tenant scope
and the query builder. To flag the pattern, a Client that is the
BaseClient of a Native router emits a structured warning whenever
RawQuery/Exec runs with a tenant resolvable from the call's context:
level=WARN msg="raw SQL under RowLevelSecurityNative sidesteps the
tenant-scoped query builder (the PostgreSQL policy still enforces
isolation)" event=quark.tenant.raw_under_native_rls op=RawQuery tenant=acme
For tenant-scoped access, use the builder against the router instead —
For[T](ctx, router) for a single statement, or router.Tx with
ForTx[T] for multi-statement work. Both open the implicit transaction
that sets app.tenant_id, so the policy sees the tenant:
err := router.Tx(ctx, func(tx *quark.Tx) error {
return quark.ForTx[Order](ctx, tx).Create(&Order{SKU: "A-1", Qty: 3})
})
client.Raw() (the bare *sql.DB accessor) takes no context, so it is
never routed through the set_config path and cannot be guarded by the
warning. The PostgreSQL policy still applies — those connections share
the same role — but with app.tenant_id unset the USING clause
returns zero rows. Reserve it for schema/maintenance work, not tenant
data.
Failure modes
Query returns zero rows when it should return data
The most common symptom of "Native is not wired correctly". Causes:
- The policy is not installed on the table. Run
SELECT * FROM pg_policies WHERE tablename = '<your_table>';and verify the policy exists and references the same setting (app.tenant_idby default). set_configwas never called on the current transaction. Happens when raw SQL is issued outsiderouter.TxorFor[T](ctx, router). The setting reads back asNULL, the policy's comparison evaluates toNULL(i.e., false), every row is filtered out.FORCE ROW LEVEL SECURITYis missing and the caller is the table owner. WithoutFORCE, the policy is decorative for the owner role. Re-emit the DDL withALTER TABLE ... FORCE ROW LEVEL SECURITY.
ErrUnsupportedFeature: RowLevelSecurityNative requires PostgreSQL
Construction-time check. The router's BaseClient is not on PG.
Native is PG-only; switch to RowLevelSecurityClient for the
non-PG dialects in your deployment.
Insert fails with new row violates row-level security policy
The seeded tenant ID disagrees with current_setting('app.tenant_id').
Check that the model's tenant_id field is set before insert and
matches the resolver's output for the current context.
Migration from RowLevelSecurityClient
The two strategies are mutually exclusive per router. To migrate an existing Client-strategy table to Native:
- Install the policy +
FORCE ROW LEVEL SECURITY. - Switch the router config to
RowLevelSecurityNativeon the next deploy. - Audit
client.Raw()callers: under Client they were filtered by the Quark builder (which they were bypassing); under Native they are filtered by the engine (the policy applies). The "you might already be leaking" path becomes "the engine catches you". - Run the test suite. Any test that relied on the Client-strategy
WHERE tenant_id = ?syntax being literally present in the SQL will need to be updated — the SQL no longer carries the predicate.
The legacy RowLevelSecurity alias still resolves to
RowLevelSecurityClient. Migration to Native is an explicit opt-in.
Limitations and roadmap
- No
LISTEN/NOTIFYintegration. Inbound channel listening stays out of scope (ADR-0013). Outbound CRUD events ship viaClient.UseEventBus. - No automatic
pg_tempcleanup. The policy stays on the table even after dropping the router; manage the policies in your normal schema migrations. - The implicit-tx executor leaks tx until
ctxends. Acceptable for HTTP handlers; not acceptable for long-lived CLI ctx. Documented above. A follow-up may add an opt-out flag or a streaming-friendly executor.
The full Phase 5 roadmap is in TASKS.md under §"Fase 5" — F5-3 (CLI generator), F5-4..F5-6 (transactional hooks + EventBus), and F5-7 (audit log).