Once you have the tenant boundary, the next question is what lives inside it. This is how I modeled teams with roles, invitations, and lifecycle states that match real organizational behavior.
This is Part 2 of the series Building Multi-Tenant Systems That Match the Real World.
Part 1: Designing Multi-Tenant Backends With Both Ownership and Team Access
Table of Contents
- The Gap Between "List of Users" and a Real Team
- The Team Member Is Not the Same as the User
- Membership Has a Lifecycle
- Roles Give Membership Meaning
- Predefined Roles vs Custom Permission Sets
- The Invitation Flow Is Part of the Data Model
- How Permission Resolution Actually Works
- What the API Surface Looks Like
- What I Would Avoid
- Closing Thought
- Suggested Diagrams
The Gap Between "List of Users" and a Real Team
In Part 1, I covered why tenants should be modeled as organizations with boundaries, not just data buckets with a tenantId column.
Once you have that boundary in place, a new question shows up immediately:
Who is inside the organization, and what are they allowed to do?
The naive answer is "just store user IDs and give them a role."
That answer falls apart quickly because a real team inside an organization is not just a list of users.
It is a set of relationships with state:
- Some people have been invited but not yet accepted
- Some have accepted and are active
- Some have been suspended temporarily
- Some have been removed but their records still matter for audit purposes
- Different people have different operating responsibilities
- The same person may have different responsibilities in a different organization they belong to
Once you recognize that, "store user IDs with a role" becomes an inadequate model.
Here is what a real team model needs to do instead.
The Team Member Is Not the Same as the User
This is the first thing I had to internalize.
A User is an identity in the system.
A TeamMember (or StoreStaff, WorkspaceMember, ClinicStaff — whatever your domain calls it) is a relationship between a user and an organization.
Those are two different things, and mixing them up causes problems.
When you mix them up, you end up with schemas like this:
interface User {
id: string;
email: string;
organizationId: string; // ← implicit membership
role: string; // ← implicit, unscoped role
}
That design has four problems:
- A user can only belong to one organization.
- Role is global, not scoped to the organization.
- There is no lifecycle for the membership itself.
- Removing someone from an organization means deleting or modifying the user record.
The cleaner design is to keep the user identity separate and make membership an explicit entity:
interface User {
id: string;
email: string;
}
interface TeamMember {
id: string;
userId: string;
organizationId: string;
roleId: string;
invitedBy: string;
invitedAt: Date;
acceptedAt?: Date;
status: MembershipStatus;
}
Now membership has its own shape, its own state, its own audit fields, and its own lifecycle.
The user can belong to multiple organizations by having multiple TeamMember records.
Removing someone from an organization means changing the status of their TeamMember record, not touching their user identity.
Membership Has a Lifecycle
Most membership models in tutorials are binary: you are either in the organization or you are not.
Real systems need at least four states:
enum MembershipStatus {
PENDING = 'pending', // invited but not yet accepted
ACTIVE = 'active', // accepted and currently active
SUSPENDED = 'suspended', // temporarily blocked
REMOVED = 'removed', // no longer a member
}
Here is why each state matters.
PENDING
When you invite someone, you should not grant them access immediately.
A pending member has been invited but has not yet confirmed they want to be part of this organization.
During this state, the membership record exists but should carry no operating permissions.
ACTIVE
Once the invitation is accepted, the member becomes active.
Active is the only state where the user should have the permissions their role grants.
SUSPENDED
Sometimes you need to temporarily block a team member without removing them entirely.
A suspended member loses all effective permissions but their record stays intact.
This is useful for dispute resolution, compliance situations, or temporary leaves.
REMOVED
A removed member is no longer a participant in the organization.
But their record should still exist for audit purposes.
You want to know that a particular action was taken while this person was an active member, even after they have been removed.
The lifecycle looks like this:
accept
PENDING ─────────────────────────────► ACTIVE ──► SUSPENDED
│ │
│ reject / cancel │ reactivate
│ ▼
└───► (record deleted) ACTIVE
│
│ remove
▼
REMOVED
Keeping this lifecycle in the schema rather than just deleting records makes your system much easier to audit and debug.
Roles Give Membership Meaning
A team member with a status is not enough. You also need to know what they are allowed to do.
That is what roles are for.
A role is not just a label. It is a named bundle of permissions scoped to a domain.
interface Role {
id: string;
name: string;
slug: string;
description?: string;
scope: 'platform' | 'organization';
isSystem: boolean;
}
interface Permission {
id: string;
name: string; // e.g. 'products.create'
resource: string; // e.g. 'products'
action: string; // e.g. 'create'
scope: 'platform' | 'organization';
}
interface RolePermission {
roleId: string;
permissionId: string;
}
A few details worth noting:
-
scopeseparates platform-wide roles from organization-specific roles. A platform admin role and an organization manager role are fundamentally different things. -
isSystemmarks built-in roles that should not be deleted. You can update their permissions, but you cannot remove them. -
sluggives you a stable identifier you can reference in code without coupling to database IDs.
With this structure, assigning a role to a team member is just setting the roleId on the TeamMember record.
Predefined Roles vs Custom Permission Sets
For most organizations, a fixed set of predefined roles covers 90% of the cases:
const PREDEFINED_ROLES = [
{
slug: 'org_manager',
name: 'Organization Manager',
description: 'Full operational access except owner-only actions',
permissions: [
'products.*',
'orders.*',
'customers.*',
'team.manage_staff',
'analytics.view',
'settings.view',
],
},
{
slug: 'order_processor',
name: 'Order Processor',
description: 'Handles order operations only',
permissions: [
'orders.view',
'orders.process',
'orders.update_status',
'customers.view',
],
},
{
slug: 'content_editor',
name: 'Content Editor',
description: 'Manages product content and media',
permissions: [
'products.view',
'products.edit',
'media.*',
'categories.manage',
],
},
{
slug: 'financial_viewer',
name: 'Financial Viewer',
description: 'Read-only access to financial data',
permissions: [
'financials.view',
'analytics.view',
'orders.view',
],
},
];
These roles should cover the most common organizational shapes without requiring configuration.
For edge cases where a specific member needs permissions that do not map cleanly to a standard role, I keep a customPermissions field on the team member record:
interface TeamMember {
id: string;
userId: string;
organizationId: string;
roleId: string;
status: MembershipStatus;
customPermissions?: string[]; // per-member overrides
invitedBy: string;
invitedAt: Date;
acceptedAt?: Date;
}
Custom permissions are additive, not replacing the role.
This means you can say "this person has the order_processor role, plus analytics.view" without creating a new role just for one team member.
Two rules I enforce:
- Custom permissions can only extend a role, not override it entirely.
- No member can be given permissions their role scope does not allow — a store-scoped team member cannot receive platform-level permissions.
The Invitation Flow Is Part of the Data Model
One of the mistakes I see teams make is treating the invitation flow as a side feature — an email is sent, and then the user is added.
The invitation state is data, and it should live in the database.
This gives you:
- a record of who invited whom and when
- the ability to list and cancel pending invitations
- the ability to resend invitations without sending duplicates
- the ability to reject an invitation gracefully
- audit history even for invitations that were never accepted
An invitation creates a TeamMember record immediately, with status PENDING.
async function inviteTeamMember(
organizationId: string,
invitedByUserId: string,
email: string,
roleSlug: string,
) {
// find or create the user account
let user = await db.user.findUnique({ where: { email } });
if (!user) {
user = await db.user.create({
data: { email, status: 'invited' },
});
// send account setup email with invitation link
await sendAccountSetupInvitation(user.email);
}
const role = await db.role.findUnique({ where: { slug: roleSlug } });
// create membership in PENDING state
const membership = await db.teamMember.create({
data: {
userId: user.id,
organizationId,
roleId: role.id,
invitedBy: invitedByUserId,
status: 'pending',
},
});
return membership;
}
When the invitee accepts, the status moves to ACTIVE and acceptedAt is stamped:
async function acceptInvitation(organizationId: string, userId: string) {
await db.teamMember.update({
where: {
userId_organizationId: { userId, organizationId },
},
data: {
status: 'active',
acceptedAt: new Date(),
},
});
}
When the invitee rejects, or an admin cancels the invitation, you have a choice: delete the record entirely or update the status. I prefer deletion for rejected and cancelled invitations because they carry no operating significance and create noise in audit logs.
async function cancelInvitation(organizationId: string, userId: string) {
await db.teamMember.delete({
where: {
userId_organizationId: { userId, organizationId },
},
});
}
How Permission Resolution Actually Works
When a request comes in and you need to know what the current user is allowed to do inside an organization, there are three questions:
- Are they the owner?
- If not, are they an active team member?
- If yes, what does their role plus any custom permissions allow?
async function resolvePermissions(
userId: string,
organizationId: string,
): Promise<string[]> {
// owners get everything
const isOwner = await isOrganizationOwner(userId, organizationId);
if (isOwner) return ['*'];
// inactive members get nothing
const membership = await db.teamMember.findUnique({
where: {
userId_organizationId: { userId, organizationId },
},
include: {
role: {
include: { permissions: true },
},
},
});
if (!membership || membership.status !== 'active') {
return [];
}
const rolePermissions = membership.role.permissions.map((p) => p.name);
const customPermissions = membership.customPermissions ?? [];
return [...new Set([...rolePermissions, ...customPermissions])];
}
Then checking a specific permission becomes a simple evaluation:
function hasPermission(
grantedPermissions: string[],
required: string,
): boolean {
if (grantedPermissions.includes('*')) return true;
if (grantedPermissions.includes(required)) return true;
const [resource] = required.split('.');
return grantedPermissions.includes(`${resource}.*`);
}
This supports three grant patterns:
-
Full wildcard (
*): used for owners -
Resource wildcard (
products.*): used for roles with broad domain ownership -
Specific permission (
products.create): used for narrow operational roles
The resolution order is always: owner check first, active status check second, permission evaluation last.
What the API Surface Looks Like
Team management operations should be first-class routes scoped to the organization:
# List current team members
GET /v1/organizations/:orgId/team
# Invite a new member
POST /v1/organizations/:orgId/team
# List pending invitations
GET /v1/organizations/:orgId/team/invites
# Resend an invitation
POST /v1/organizations/:orgId/team/invites/:userId/resend
# Cancel an invitation
DELETE /v1/organizations/:orgId/team/invites/:userId
# Accept your own invitation (self-service)
PUT /v1/organizations/:orgId/team/me/accept
# Reject your own invitation (self-service)
PUT /v1/organizations/:orgId/team/me/reject
# View your own effective permissions (self-service)
GET /v1/organizations/:orgId/team/me/permissions
# Update a member's role (admin)
PUT /v1/organizations/:orgId/team/:userId/role
# Suspend a member (admin)
PUT /v1/organizations/:orgId/team/:userId/suspend
# Reactivate a suspended member (admin)
PUT /v1/organizations/:orgId/team/:userId/reactivate
# Remove a member (admin)
DELETE /v1/organizations/:orgId/team/:userId
A few structural points worth noting:
- Self-service endpoints (
/me/...) are separated from admin endpoints because they require different authorization logic. A member can accept or view their own permissions without needingteam.manage_staff. - Suspension and removal are separate endpoints because they have different semantics, different audit implications, and different reversal paths.
- The invitation list is a distinct route so it can be paginated, filtered, and canceled without mixing pending invitations into the active member list.
What I Would Avoid
1. Granting permissions the moment an invitation is sent
A PENDING member has been invited, not admitted. Treat those two events differently.
2. Deleting team member records on removal
You lose audit history. Use a REMOVED status instead.
3. Mixing customPermissions with role replacement
Custom permissions should extend a role. If someone needs a very different permission set, create a new role rather than stacking overrides on a single record.
4. Skipping the status check in permission resolution
The permission resolution path must always check status === 'active' before returning any permissions. A suspended member's role may still exist in the database. Status gating is the only reliable guard.
5. Encoding team structure in the user table
Adding a role column to the user record makes multi-organization behavior nearly impossible to support cleanly. Membership is a relationship, not a user attribute.
6. Letting the same role exist with different meanings in different organizations
If your predefined roles are system roles, they should mean the same thing everywhere. The organization-specific layer should be in the assignment and optional customization, not in redefining what store_manager means per store.
Closing Thought
The key shift in this article is the same one from Part 1, applied one level deeper:
A team is not a list of users. It is a set of active relationships with state, roles, and boundaries.
Once you model it that way:
- invitations become first-class data instead of fire-and-forget emails
- lifecycle management becomes natural instead of hacky
- permission resolution becomes a straightforward function instead of scattered conditional logic
- multi-organization membership becomes a non-event instead of a schema crisis
The patterns here — separate the membership from the identity, give membership a lifecycle, resolve permissions from status and role together — apply well beyond marketplace systems.
Any B2B SaaS tool, internal operations platform, healthcare system, or agency tool that has "workspaces with multiple people" needs to make these same decisions eventually.
Modeling them deliberately at the start is much cheaper than untangling them later.
In Part 3, I will cover how to separate platform-level authority from organization-level authority — the point where "admin" starts to mean different things depending on who is asking.
Top comments (0)