DEV Community

Cover image for How to Model Teams Inside a Multi-Tenant Product
Oladele David
Oladele David

Posted on

How to Model Teams Inside a Multi-Tenant Product

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

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
}
Enter fullscreen mode Exit fullscreen mode

That design has four problems:

  1. A user can only belong to one organization.
  2. Role is global, not scoped to the organization.
  3. There is no lifecycle for the membership itself.
  4. 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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

A few details worth noting:

  • scope separates platform-wide roles from organization-specific roles. A platform admin role and an organization manager role are fundamentally different things.
  • isSystem marks built-in roles that should not be deleted. You can update their permissions, but you cannot remove them.
  • slug gives 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',
    ],
  },
];
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Custom permissions can only extend a role, not override it entirely.
  2. 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;
}
Enter fullscreen mode Exit fullscreen mode

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(),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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 },
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Are they the owner?
  2. If not, are they an active team member?
  3. 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])];
}
Enter fullscreen mode Exit fullscreen mode

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}.*`);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 needing team.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)