Row-level security
Many multi-tenant PostgreSQL applications use row-level security (RLS) as a second wall between tenants. The application connects as a role that can’t bypass it, and each transaction sets the tenant, for example with set_config('app.organization_id', $1, true). Policies then hide every other tenant’s rows.
wardn works in this setup. This page covers the four things to get right.
1. wardn’s tables don’t use RLS, by design
Section titled “1. wardn’s tables don’t use RLS, by design”wardn’s tables (wardn_assignments, wardn_scope_links, wardn_roles, …) have no tenant column. A scope is just a type and an ID, and wardn serves every tenant from the same tables. Tenant isolation comes from the scope tree: every query wardn makes names the user and the exact scopes in the checked scope’s chain, and wardn filters the results again itself, never trusting the database to have done it (threat T01).
So don’t enable RLS on wardn’s tables. A policy keyed on your tenant setting would hide assignments wardn needs, such as a global support role or a parent scope’s assignments, and checks would quietly deny. If a schema test requires every table to have RLS, exempt wardn’s tables by prefix, and record why.
What protects wardn’s tables instead: only your application’s database role can reach them, and only through wardn.
2. Grant the application role what wardn needs
Section titled “2. Grant the application role what wardn needs”Run migrations as the schema owner and give the restricted role data access only:
w, err := wardn.New(wardn.Config{ Store: store, ManualMigrations: true, // the app role can't run DDL // ...})-- in your migration job, after store.Migrate(ctx) has run as the ownerGRANT SELECT, INSERT, UPDATE, DELETE ON wardn_assignments, wardn_scope_links, wardn_roles, wardn_role_rules, wardn_role_assignable TO app;GRANT SELECT ON wardn_schema_migrations TO app;If you change the table prefix with WithTablePrefix, use that prefix here.
3. Write inside the tenant transaction
Section titled “3. Write inside the tenant transaction”To change access together with your own rows, pass the tenant transaction to wardn (changes in your own transaction):
With the pgx store, tx is your pgx.Tx; with the SQL store, a *sql.Tx:
wctx, ended := w.Transaction(ctx)err := database.InTenantTx(wctx, pool, orgID, func(tx pgx.Tx) error { ctx := store.WithTx(wctx, tx) // your writes on tx, then: return w.Assign(ctx, userID, "agent", product)})if endErr := ended(ctx); endErr != nil { // after InTenantTx has committed log.Warn("wardn cache invalidation failed", "err", endErr)}If you don’t have such a helper, store.RunInTx works too: make the tenant setting the first statement in your function, for example tx.Exec(ctx, "SELECT set_config('app.organization_id', $1, true)", orgID).
wardn’s queries run with whatever the transaction has set, including SET LOCAL ROLE and the tenant setting. That’s harmless for wardn’s own tables, and it is exactly what your ParentOf needs (below).
4. Make ParentOf see the parent, or store the links
Section titled “4. Make ParentOf see the parent, or store the links”If scope parents come from your own tables through Config.ParentOf, those tables are probably RLS-protected. Outside a tenant transaction, such as in middleware that checks before opening one, the policy hides the row, ParentOf finds no parent, and the check fails closed: an organisation owner is denied on that organisation’s product. It is safe, but it is wrong.
Pick one of these:
-
Store the links in wardn. Call
w.Link(ctx, product, org)in the same transaction that creates the product. Parents are then read fromwardn_scope_links, which has no RLS. For most applications this is the simplest choice. -
Read the parent through a narrow function. A
SECURITY DEFINERSQL function that returns only a product’s organisation ID, owned by a role that may read it:CREATE FUNCTION app.product_organization(product uuid) RETURNS uuidLANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, publicAS $$ SELECT organization_id FROM products WHERE id = product $$;REVOKE ALL ON FUNCTION app.product_organization(uuid) FROM PUBLIC;GRANT EXECUTE ON FUNCTION app.product_organization(uuid) TO app;It reveals only which organisation a product ID belongs to, which is all
ParentOfneeds. -
Use the tenant transaction. If
ctxcarries one (store.InTx(ctx)), query through it. Only checks made inside a tenant transaction can then see parents.
Checklist
Section titled “Checklist”- wardn’s tables have no RLS, and are exempted by name in any schema test.
- The app role has
SELECT, INSERT, UPDATE, DELETEon wardn’s tables andSELECTon its migrations table, and nothing more. - Migrations run as the owner, with
ManualMigrations: truein the application. - Changes to access that go with your own writes use
store.RunInTx, orstore.WithTxandw.Transaction. - Parents are stored with
Link, orParentOfcan read them outside a tenant transaction. - Integration tests connect as the restricted role.
Questions about this page?
Ask below, point out something wrong or unclear, or suggest an improvement. Reading needs no account; to post, sign in with GitHub.