Multi-tenancy
GStack uses a single database with a shared schema. Every table carries a
tenant_id, and RLS scopes rows through a current_tenant_id() security-definer
helper that reads the caller's profile.
Signup creates a tenant and makes the registrant its admin, via a
handle_new_user trigger. profiles.id is the auth user id — there is no
second identity table to keep in sync.
Roles
member and admin. RLS is the real security layer; the roles key in
definePageMeta is UX only — it stops a member from navigating to a page that
would render empty anyway.
definePageMeta({ roles: ['admin'] })
Why app code doesn't scope queries
The reference CRUD (layers/notes) never filters by tenant or owner:
const { data: notes } = await useAsyncData('notes', async () =>
supabase.from('notes').select('*').order('created_at', { ascending: false }),
)
There is no .eq('tenant_id', …) because the policy already applied it. Scoping in
both places means two things to keep correct, and the app-layer one is the one
that silently rots.
Proving it
Claiming isolation is cheap. e2e/tenant-isolation.spec.ts logs in as an admin of
a second seeded tenant and asserts the first tenant's rows are invisible through
the real HTTP surface — not through a mocked client.
That test is the reason a widened policy fails in CI instead of in production.
Scaling path
Reading the tenant from the caller's profile costs a lookup per policy evaluation.
The upgrade — moving tenant_id into a JWT claim — is noted in the migration
itself, so the person who hits the ceiling finds the note where they're already
looking.