Tenant isolation (RLS)
Every Wisteria workspace is a separate tenant. Data from one workspace must never be visible to another, even by accident — even via a developer mistake. The primary mechanism that enforces this is Postgres row-level security (RLS).
The model
Wisteria’s database is shared across all workspaces. There’s one users table, one courses table, one modules table — and so on. Every row has a company_id column (the workspace identifier).
RLS is a Postgres feature that filters rows automatically based on policies attached to each table. When a user issues a query, the database adds the relevant policy’s WHERE clause to it transparently. The user sees only rows matching the policy.
In Wisteria’s case, the policy is roughly: “You can only see rows where company_id matches your own workspace’s company_id.” Enforced for SELECT, INSERT, UPDATE, and DELETE.
Why we use RLS (instead of application-level filters)
Application-level filtering — “every query should include a WHERE company_id = X clause” — works when every developer remembers to write it. RLS works even when developers forget.
The risk of relying on app-level filtering: one missed WHERE clause in one query, one careless join, one bug — and one workspace’s data leaks to another. With RLS, the database itself adds the clause; the developer can’t forget.
How it composes with our backend
Wisteria’s backend uses Supabase. When a request comes in:
- The user’s JWT token identifies them.
- The backend passes the token to Supabase.
- Supabase resolves the token to a
user_id. - RLS policies use that
user_idto look up the user’scompany_id(the workspace). - Every query then filters by that
company_id.
This is all server-side. The frontend never sees raw data outside the tenant; the backend never sees raw data outside the tenant.
Helper functions
Common policy logic lives in SECURITY DEFINER Postgres functions:
get_my_company_id()— returns the current user’s workspaceget_my_role()— returns the current user’s rolecan_see_course(course_id)— composite check for content distributionis_org_admin_for(target_company_id)— for cross-workspace org governance
These are battle-tested in Wisteria’s codebase. New tables get policies that compose existing helpers rather than inlining the same checks every time.
What this means for customers
- Your data is invisible to other Wisteria customers — at the database level, not just the app level.
- A bug in one developer’s code can’t expose your data to a different tenant. RLS gives even buggy code a safety net.
- Wisteria employees with database access can see your data. RLS protects tenant-vs-tenant; it doesn’t protect customer-vs-Wisteria. See Token storage per provider for our handling of access tokens specifically.
Service-role bypass
Postgres has a “service role” that bypasses RLS — Wisteria uses it for specific server-side operations (user creation, audit log writes, cross-workspace cron jobs). Service-role usage is:
- Limited to specific routes — an allowlist enforced by linting. A service-role import outside the allowlist fails the build, so the surface can’t grow by accident.
- Justified per route — each route using the bypass has a documented reason
There is no separate log of service-role calls. Actions taken through these routes appear in the audit log where that route writes one (user creation, for example), but the bypass itself isn’t independently recorded.
The roadmap is to drive service-role usage to zero except for the smallest unavoidable surface.
Storage isolation
File storage (cover images, certificate templates, integration files) uses Supabase Storage. Buckets have RLS-equivalent policies that scope access by workspace.
module-covers— public bucket; URLs are unguessablemodule-sources— private bucket; only the workspace owners can list/readcertificate-templates— private; per-workspace access only
What’s NOT in RLS
A few categories Wisteria doesn’t currently enforce via RLS:
- Anthropic / OpenAI API calls — when Wisteria sends data to Claude or Whisper, that’s a network call out of the database, so RLS doesn’t apply. The data goes to the AI provider scoped to the tenant making the call. See PII & encryption at rest.
- Static assets in the public directory — fonts, logos, the Wisteria mark. Public on purpose.
- Audit log writes — done via service role for performance. The reads are RLS-gated (audit logs are visible only to your own workspace’s super_admin + auditor).
Verification
Customers occasionally ask: “How do I verify my data is isolated?”
Two signals:
- The RLS policy itself. Available on request — every tenant-scoped table has its policy text published.
- Our authorization test results. We run an automated probe that hits every endpoint as each role, as an unauthenticated caller, and as a second tenant, asserting that each is correctly denied. The most recent run covered 460 checks across 108 endpoints. Results available on request.
We have not had a third-party penetration test. The testing above is our own. If an independent pentest is a procurement requirement for you, tell us — it helps us prioritise.
What if you suspect a leak
Email security@getwisteria.com. We treat this as the highest-priority alert.
The contact can be used for any kind of suspected breach or unexpected access — credentials, API surface, data, anything.