Skip to main content
Version: 1.2.2

PostgreSQL Native Row-Level Security

RowLevelSecurityNative pushes tenant isolation into PostgreSQL. Each query runs in a transaction that first calls set_config('app.tenant_id', <tenant>, true), and a CREATE POLICY on each tenant table filters rows against that setting. The payoff over the client-side strategy: Quark cannot bypass the policy even from client.Raw() — the engine itself enforces isolation.

It's PostgreSQL-only. On the other five engines, use RowLevelSecurityClient (client-side WHERE injection); attempting Native elsewhere fails at construction:

_, err := quark.For[Order](tenantCtx, nativeRouter).List() // non-PG base client
// errors.Is(err, quark.ErrUnsupportedFeature) == true

Native vs Client — when to use which​

ConcernRowLevelSecurityNativeRowLevelSecurityClient
Where the filter runsPostgreSQL engineQuark query builder
client.Raw() / Exec()Filtered by the policyBypasses the predicate
DialectsPostgreSQL onlyAll six
SetupOne CREATE POLICY per tableNone
Per-query costImplicit transaction + set_configNone
Use whenProduction; bypass risk mattersCross-dialect dev / staging

They're mutually exclusive per router — Native isn't a drop-in upgrade of Client. The two enforce isolation in different places: Native leans on real PostgreSQL policies and a transaction-scoped session variable, so the database itself rejects rows the tenant may not see, even through raw SQL. Client mode only adds a predicate to queries Quark builds. Switching means installing policies, not flipping a stricter flag.

Setup​

1. Install the policy on each tenant table​

Either run the DDL yourself, or embed the quarktenant library and let it emit the DDL from your registered models.

Option A — quarktenant (recommended). A tiny main.go registers your models and delegates to quarktenant.Run, which emits one ALTER TABLE … ENABLE/FORCE ROW LEVEL SECURITY pair plus one CREATE POLICY <table>_tenant_isolation per table:

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))
}
go run ./cmd/tenant install-rls-policies --dry-run # print the DDL, no change
go run ./cmd/tenant install-rls-policies # apply (takes a migration lock first)
go run ./cmd/tenant install-rls-policies --tenant-col=org_id --native-rls-var=app.org_id

A runnable example is at examples/tenant-rls-native/.

Option B — manual DDL through your normal 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);

Two parts matter: WITH CHECK stops a tenant from writing rows for another tenant (without it, isolation is read-only), and FORCE ROW LEVEL SECURITY removes PostgreSQL's default exemption for the table owner — which your app role usually is, so without FORCE the policy would be decorative.

UUID / BIGINT tenant IDs need an explicit cast

current_setting() returns TEXT. A TEXT/VARCHAR tenant column works with the default ::text; a UUID or BIGINT column needs --cast=uuid (or ::uuid / ::bigint in manual DDL), or the comparison fails 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 to match your policy
router := quark.NewTenantRouter(cfg, ResolveTenant, nil)

3. Query — both paths set the tenant transparently​

// Recommended for multi-step work: one transaction, one set_config.
err := router.Tx(tenantCtx, func(tx *quark.Tx) error {
orders, err := quark.ForTx[Order](tenantCtx, tx).Where("status", "=", "paid").List()
// ...
return err
})

// Single short read: implicit transaction per call.
orders, _ := quark.For[Order](tenantCtx, router).Where("status", "=", "paid").List()

Both reach PostgreSQL with app.tenant_id set to the resolved tenant; the policy filters server-side.

Use router.Tx for anything non-trivial

For[T](ctx, router) wraps each call in an implicit transaction (needed because set_config(..., is_local=true) only applies inside one). That transaction commits when the caller's ctx ends, not when rows close — fine for a request-scoped HTTP handler, but a long-lived CLI ctx would hold a connection per query and can saturate the pool. So for batch jobs and for Iter/Cursor streaming, run inside router.Tx.

Raw SQL under Native​

client.RawQuery / client.Exec go straight to the pool, so they skip the implicit transaction that sets app.tenant_id. Unlike the client-side strategy, this is not a hole under Native: with no tenant set, the policy's USING returns zero rows and an INSERT fails WITH CHECK — the engine stays in control. It's still rarely what you want (you lose the tenant scope and the builder), so a Native router's base client logs a warning when raw SQL runs with a resolvable tenant:

event=quark.tenant.raw_under_native_rls op=RawQuery tenant=acme

For tenant-scoped access, use the builder against the router (For[T] or router.Tx + ForTx[T]). client.Raw() takes no context, so it's never routed through set_config — reserve it for schema/maintenance, not tenant data.

Troubleshooting​

Query returns zero rows when it shouldn't — the usual sign Native isn't fully wired:

  1. The policy isn't installed — check SELECT * FROM pg_policies WHERE tablename = '<table>'.
  2. set_config never ran for this query — raw SQL outside router.Tx / For[T]; the setting reads NULL and filters everything out.
  3. FORCE ROW LEVEL SECURITY is missing and the caller owns the table — re-emit the DDL with FORCE.

ErrUnsupportedFeature: RowLevelSecurityNative requires PostgreSQL — the router's base client isn't on PG. Switch to RowLevelSecurityClient for non-PG.

new row violates row-level security policy — the row's tenant_id disagrees with current_setting('app.tenant_id'); make sure the field is set and matches the resolver for the current context.

Migrating from the client-side strategy​

  1. Install the policy + FORCE ROW LEVEL SECURITY.
  2. Switch the router to RowLevelSecurityNative on the next deploy.
  3. Re-audit client.Raw() callers — under Client they bypassed the builder's filter; under Native the engine's policy now catches them.
  4. Update any test that asserted the literal WHERE tenant_id = ? text — the SQL no longer carries it (the engine does).

Limitations​

  • No LISTEN/NOTIFY integration here — outbound CRUD events ship via Client.UseEventBus.
  • Policies aren't auto-removed when you drop the router; manage them in your schema migrations.
  • The implicit-For[T] transaction holds its connection until ctx ends — fine for request scope, not for long-lived CLI contexts. Use router.Tx there.