Lesson 9 · Row-Level Security & the whole chain
RLS & multi-tenancy
The last gate — enforced by the database itself. Every row carries a tenant, and Postgres refuses to hand you another tenant's rows.
Your win: explain how resource_path travels from the JWT into a
Postgres session variable, and how one function — permission_check — makes tenant
isolation automatic on every query, even a raw one.
The idea: the database enforces tenancy
Tiers 1 and 2 (Parts 1–2) all ran in Go — the interceptor, the role gate, the service check. They
work only if the application remembers to check. Row-Level Security is
different: it's enforced by Postgres, on the table, so even a hand-written
SELECT * FROM users returns only rows the caller is allowed to see.1
Here, the first thing RLS enforces is the tenant: your organization
(resource_path).
Step 1 — the JWT's tenant becomes a session variable
Recall resource_path from the Manabie claim (Lesson 2). When the pgx
pool hands a connection to a request, a BeforeAcquire hook stamps that claim onto the
connection as a Postgres session variable — every acquire:
claims := interceptors.JWTClaimsFromContext(ctx) // the verified JWT (from the interceptor)
conn.QueryRow(ctx,
"SELECT set_config('permission.resource_path', $1, false)," +
" set_config('permission.user_group', $2, false)," +
" set_config('app.user_id', $3, false)",
claims.Manabie.ResourcePath, claims.Manabie.UserGroup, claims.Manabie.UserID)
// verifies the echo; returns FALSE (refuses the connection) on any mismatch
BeforeAcquire.)2
Step 2 — permission_check filters every row
Now the database has the tenant in a session variable. Two tiny SQL functions turn that into enforcement:
FUNCTION autofillresourcepath() → current_setting('permission.resource_path') -- for INSERTs
FUNCTION permission_check(rp, t) → ( rp = current_setting('permission.resource_path') ) -- the tenant test
And every tenant-scoped table gets a policy built from permission_check, plus
ENABLE and FORCE ROW LEVEL SECURITY:
CREATE POLICY rls_permission ON permission
USING ( permission_check(resource_path, 'permission') ) -- which rows I can READ
WITH CHECK ( permission_check(resource_path, 'permission') ); -- which rows I can WRITE
ALTER TABLE permission ENABLE ROW LEVEL security;
ALTER TABLE permission FORCE ROW LEVEL security;
USING filters reads — a row is visible only if its
resource_path equals the session's tenant. WITH CHECK
filters writes — you can't insert or update a row into another tenant.
FORCE ROW LEVEL SECURITY is the crucial one: by default Postgres lets
the table owner bypass RLS entirely — the single most common RLS footgun. FORCE
binds even the owner, so the application's own DB user can't escape tenancy.2
And autofillresourcepath() is the DEFAULT for every
resource_path column (you saw it on users in Lesson… well, in the
migration): a new row auto-stamps the current tenant, so a developer can't forget to set
it, and WITH CHECK would reject it anyway if it were wrong.
resource_path is never taken from the request — only from the JWT
(Lesson 2). RLS is what makes that rule unbreakable: even if a handler tried to query
another tenant's data, Postgres wouldn't return it, because the session variable came from the
verified token, not from anything the caller controls. Tenant isolation you can't code your way
around.
Postgres Row-Level Security for multi-tenancy
The mechanism itself, and the two gotchas this design handles — pooled-connection context and the owner bypass.
→ postgresql.org — Row Security Policies
→ AWS — multi-tenant RLS ·
Crunchy Data — RLS for tenants
Check yourself (from memory)
Q1. How does the tenant (resource_path) get into the database session?
setPostgres runs set_config(...) from
JWTClaimsFromContext per acquire, refusing the connection on mismatch.
Q2. What does permission_check(resource_path, 'permission') evaluate?
resource_path must
equal the session's. That single equality is the isolation core.
Q3. Why does the schema use FORCE ROW LEVEL SECURITY, not just ENABLE?
FORCE is essential.
resource_path from the JWT →
setPostgres BeforeAcquire (pool_gcp.go:63,161) runs
set_config('permission.resource_path'/'permission.user_group'/'app.user_id', …) per
connection acquire (refuses on mismatch — solves the pooler gotcha). Functions:
permission_check(rp,t) = rp = current_setting('permission.resource_path')
(tenant test); autofillresourcepath() = DEFAULT for resource_path cols
(auto-stamp on insert). Policy: CREATE POLICY rls_<t> USING
(permission_check(...)) WITH CHECK (permission_check(...)) — USING filters reads, WITH CHECK
filters writes — plus ENABLE + FORCE ROW LEVEL SECURITY
(binds the owner too — the #1 RLS footgun). resource_path is never from the request;
RLS makes that unbreakable.resource_path against it via permission_check. We
FORCE RLS so even the table owner is bound, and default the column from the session so
inserts auto-stamp. The tenant is never client-supplied — so it can't be coded around."
app.user_id
earns its keep. Ask me about the pooler or owner gotchas if they're new.
1. Postgres RLS. In-repo: migrations/auth/1001_migrate.up.sql:1,14, migrations/bob/1182_migrate.up.sql:12-14.
2. Pooler + owner gotchas: AWS multi-tenant RLS. In-repo: internal/golibs/database/pool_gcp.go:63,161-206.