Membership Isn't Tenant Isolation
Organizations are not complete because membership exists. CoreMVP treats authorization and tenant-scoped data access as separate boundaries that need separate proof.
A user can be a valid member of an organization and still read the wrong organization's data if the query boundary is wrong.
That is why we do not treat an organization table, workspace switcher, or membership check as proof of multi-tenant safety.
Membership answers whether this user may act in Organization A.
Tenant isolation answers whether this operation can touch only Organization A's records.
A serious Organizations capability needs both.
Authorization and isolation fail differently
Imagine a user belongs to Organization A.
The server receives this request:
GET /organizations/A/projects/PROJECT_IDIt checks the user's membership in Organization A. The check passes.
Then the repository loads the project using only PROJECT_ID.
If that ID belongs to Organization B, the membership policy did its job and the data query still crossed the tenant boundary.
The bug is not missing authentication. It is not even missing organization authorization.
The request authorized one organization and queried data without carrying that organization into the data boundary.
The second scope is not redundant. It connects the authorization decision to the records being touched.
What we prove for Organizations today
For Organizations and Organization-owned Projects, we hold the supported capability to a stricter standard than "membership exists."
For the shipped organization data:
- the API authenticates the current Supabase user;
- protected actions reread the current persisted organization role;
- organization repository reads carry the target organization or user membership in their predicates;
- browser
anonandauthenticateddatabase roles have no direct access to the Organizations tables; - focused local E2E proves allowed organization behavior and rejects cross-tenant reads and invitation writes.
That proof applies to the capability we actually ship: organizations,
organization_memberships, organization_invitations, and Organization-owned
projects.
A future documents, invoices, or records table is a new authority surface.
It must declare its own tenant ownership and prove its own isolation path.
That is not a gap hidden behind the word "multi-tenant." It is the correct boundary. We do not let an Organizations feature make claims for product tables that do not exist yet.
The server database connection is another authority boundary
Our application repositories use a server-only DATABASE_URL.
That keeps privileged persistence out of the browser, but it also means every server repository operation must preserve the tenant decision it was authorized to make.
You cannot assume a browser-side database policy automatically scopes a privileged server query.
For a project owned by an organization, the repository contract should look conceptually like this:
findProject({
organizationId,
projectId,
})not:
findProject(projectId)The SQL or ORM predicate then carries both identities:
where organization_id = :organization_id
and id = :project_idThe same rule applies to updates and deletes.
A role check before the repository call does not add that predicate for you.
Put the tenant key in the data model
Isolation becomes fragile when the table itself has no clear tenant owner.
If a record belongs to an organization, make that ownership explicit in the schema.
For example:
Whether a child repeats organization_id or derives it through a parent depends on the data model. The important part is that every access path has an unambiguous tenant scope that can be enforced and tested.
Without that, developers tend to infer tenancy from whichever object they already loaded:
- the current workspace in the UI;
- session state;
- route state;
- a parent object loaded earlier.
Those can help select a target. They are weak substitutes for a data-access contract.
RLS is defense in depth, not a magic word
Row-level security can add another strong boundary when the database has enough trustworthy identity and tenant context to enforce it.
But "we use RLS" is not proof by itself.
You still need to know:
- which database role the application uses;
- what identity or tenant context reaches the database;
- whether privileged server paths follow the same policy;
- which tables have policies;
- how cross-tenant negative cases are tested.
Our shipped migration enables RLS on the Organizations and Projects tables and revokes direct browser-role access. The server-side application path still owns its authorization and repository predicates.
If your product adds a second isolation mechanism for new tables, define exactly what it protects and keep the same negative proof. Defense in depth is valuable because the layers reinforce one another, not because the acronym exists.
Negative tests are the useful proof
Happy-path tests prove that a member can see their own data.
That is not tenant-isolation proof.
A useful test creates at least two tenants and asks the wrong tenant to perform the same read or write.
For organization-owned product data, useful cases include:
| Case | Expected boundary |
|---|---|
| Member of A reads an A record | Allowed |
| Member of A reads a B record | Denied or not found |
| Member of A updates a B record | Denied or not found |
| Outsider reads an A record | Denied or not found |
| Removed member reads an A record | Denied or not found |
The exact HTTP status is a product choice. The invariant is that the wrong tenant cannot make the repository return or mutate the record.
We prove this pattern for the Organizations and Projects objects we ship. When you introduce a new tenant-owned table, the new table inherits the standard, not imaginary proof from another capability.
That distinction is important. It tells you exactly where the next engineering obligation begins.
Explicit authority beats hidden convenience
Passing organizationId through services and repositories can feel repetitive.
Centralized tenant context can reduce that repetition. Database policies can reduce it further.
Every abstraction also moves authority somewhere less visible.
A global "active organization" can become stale. Middleware can miss a route. An ORM helper can be bypassed. A database policy can run under a role with different privileges than you assumed.
The goal is not to eliminate repeated tenant context at any cost.
The goal is to make the scope hard to forget and easy to prove.
For a startup foundation, explicit tenant parameters and explicit negative tests are a strong default because the authority is visible at the call site. If the product later needs a more implicit model, move the authority deliberately and preserve the same proof.
What Organizations support means in CoreMVP
We do not call Organizations complete because a dashboard can switch workspaces.
The supported path separates and proves:
- authenticated identity;
- persisted organization authority;
- bounded invitation transitions;
- organization-scoped repositories;
- browser/database access boundaries;
- cross-tenant negative cases for the data the capability actually owns.
That lets a founder build product behavior on top of Organizations without first auditing whether the foundation confused membership with isolation.
The reusable rule is simple:
- Authorization: this user may perform this action for Organization A.
- Isolation: this operation can only read or mutate records owned by Organization A.
Then find the code and the negative test that prove each statement.
"Organizations exist" proves neither by itself. We treat both as part of the capability because the small boundary mistakes are exactly the ones that become expensive after real customer data arrives.
Read Organizations for the shipped authority model and Test Organizations for its negative proof. When you add organization-owned application data, start with Database and give that table its own tenant contract.
I'm Antoine. I build CoreMVP and write about the problems I run into, the decisions I make, and what I learn along the way.