RLS and multi-tenancy
Every Fayz app is multi-tenant: many customers (tenants) share the same database. Isolation between them is not enforced in application code, and does not come from separating schemas — it's enforced in the database, with Postgres Row Level Security (RLS). RLS is the model's isolation boundary: one tenant's row is physically invisible to the others, even if the application gets it wrong.
The single rule: tenant_id + RLS
The rule is the same across every layer of the data model — core, plugin plg_ tables and your app's tables: every table carries a tenant_id column and has an RLS policy keyed on it. The canonical form scopes the row to the tenants the logged-in user belongs to:
CREATE POLICY tenant_isolation ON <table> USING (tenant_id IN ( /* tenants of the logged-in user */ ));
The user's set of tenants comes from a function in the public schema, resolved from the user's membership in each tenant. The policy applies to SELECT, INSERT, UPDATE and DELETE. The result: a query never sees a row from a tenant the user doesn't belong to — Postgres filters it out before the application ever sees it.
The concept — a policy scoped by tenant_id to the user's set of tenants — is stable. The exact name of the function and the literal SQL text depend on the installed package wave and their labels may change; treat the SQL above as the shape, not as a fixed signature.
Website and admin: same tables, different roles
Tenant isolation is only the first axis. The second is who, inside the tenant, sees what — and that lives in RLS too, over the same tables. The site side and the admin side don't have separate tables (public_*/admin_*); they have different policies and roles over the same table: staff sees the tenant's rows, the end customer sees only their own. That's why Fayz never duplicates tables per surface — one table, several views, all guaranteed by the database.
Canonical vs. deferred
Not every plugin writes the policy inline, and that's intentional:
- canonical — the policy writes the canonical form (
tenant_idscoped to the user's set of tenants) directly in the plugin's migration. - deferred — the plugin enables RLS (
ENABLE ROW LEVEL SECURITY) but does not create the policy; the table (with atenant_idcolumn, typeBASE TABLE, no_prefix) is picked up by an auto-detection block in the app, which emits exactly the canonical policy at apply time.
A deferred plugin therefore lands as canonical in a real database — it isn't a divergence, just a deferral of where the policy text lives. Both forms produce the same isolation. Any other form (divergent, no-rls) is treated as a problem to standardize before merging.
Your app's tables isolate too
The tables your app or incubator creates are no exception. A customers table or an appointments table you add must carry tenant_id and have RLS. It's easy to forget, because those tables are yours; the rule applies to them just the same — and to plugins' plg_ tables.
Migrations are append-only once applied
One operational detail that avoids silent corruption: a migration that has already run against any real database is immutable. Fix it with a new migration, never by editing the file — the file would silently drift from the database.
The exception is before the first apply: a greenfield app, or a plugin's source schema that hasn't run yet, can be edited in place (it's even preferable — a fresh apply should produce the final shape directly). Rule of thumb: already touched a live database? → new migration. Never applied? → edit in place.
You almost never write these policies by hand. fayz db apply emits the canonical form for you, and the model's table pattern (core, plg_, your tables) already ensures every new table lands isolated. What this page gives you is the why — so you can recognize a badly isolated table when you see one.
Next: how the logged-in user is resolved in Auth — overview.