How to Build SaaS Team & Organization Management

How to Build SaaS Team & Organization Management

Profile-Image
Bright SEO Tools in saas Published: Apr 04, 2026 | Updated: Apr 04, 2026 · 2 months ago
0:00

How to Build SaaS Team & Organization Management

A customer just upgraded to your team plan and invited 15 colleagues. One user accidentally deleted critical data, but you can't identify who because your permissions system only tracks "admin" and "member" roles. Another organization is hitting usage limits because a former employee's account is still consuming API quota three months after they left. These scenarios cost B2B SaaS companies millions in support overhead and churn annually—yet they're entirely preventable with proper team and organization management.

This article covers the complete architecture for multi-tenant team management in B2B SaaS applications, from data models that scale to thousands of organizations through role-based access control, invitation workflows, and seat management. You'll learn the patterns that Slack, Notion, and GitHub use to handle complex org hierarchies, the database schemas that prevent data leaks between organizations, and the permission systems that balance flexibility with security.

We'll start with the foundational data model distinguishing users from organizations, progress through invitation and onboarding flows, implement role-based permissions with custom roles, and conclude with seat management and billing integration.

The User-Organization-Membership Data Model

The core pattern for team management separates three concepts that developers often conflate: users (individual accounts), organizations (teams/companies), and memberships (the relationship between users and organizations). A single user can belong to multiple organizations with different roles in each. This many-to-many relationship requires a join table that stores role and permission information.

The anti-pattern is storing organization data directly on the user model or making users children of organizations. Both approaches fail when a user needs to access multiple organizations or switch between them. The correct schema treats users and organizations as independent entities connected through memberships.

-- Core team management schema
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email VARCHAR(255) NOT NULL UNIQUE,
  name VARCHAR(255) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  email_verified BOOLEAN NOT NULL DEFAULT false,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL,
  slug VARCHAR(100) NOT NULL UNIQUE,
  plan VARCHAR(50) NOT NULL DEFAULT 'free', -- 'free', 'team', 'business', 'enterprise'
  max_seats INTEGER NOT NULL DEFAULT 5,
  billing_email VARCHAR(255),
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE TABLE organization_members (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  role VARCHAR(50) NOT NULL DEFAULT 'member', -- 'owner', 'admin', 'member', 'guest'
  invited_by UUID REFERENCES users(id),
  joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE(organization_id, user_id)
);

CREATE INDEX idx_org_members_user ON organization_members(user_id);
CREATE INDEX idx_org_members_org ON organization_members(organization_id);
CREATE INDEX idx_org_members_role ON organization_members(organization_id, role);

This schema enables the fundamental queries: find all organizations a user belongs to, find all members of an organization, check a user's role in a specific organization. The CASCADE deletes ensure data consistency—when an organization is deleted, all memberships are automatically removed. The unique constraint prevents duplicate memberships.

The slug field on organizations provides human-readable URLs (yourapp.com/acme-corp instead of yourapp.com/550e8400-e29b-41d4-a716-446655440000). Generate slugs from organization names at creation time, ensuring uniqueness through validation. Slugs also appear in API endpoints and make audit logs more readable.

One critical design decision: what happens when a user account is deleted? CASCADE delete removes all memberships, but in B2B SaaS, this can be problematic—the organization loses access to data created by that user. The alternative is soft deletion: add a deleted_at timestamp to users and filter deleted users from queries while preserving their memberships and data ownership.

Key Insight: The organization is the billing and permission boundary, not the user. All data, usage limits, and subscription features are scoped to organizations. Users are simply actors who interact with organization resources through their memberships.

Role-Based Access Control (RBAC) Foundations

Role-based access control maps memberships to permissions. Instead of checking "is this user the owner of organization X" before allowing an action, you check "does this user have the required permission in organization X." This abstraction lets you change what each role can do without modifying permission checks throughout your codebase.

Start with four standard roles that cover 90% of B2B SaaS use cases: Owner (full control, including billing), Admin (manage members and settings), Member (use the product), and Guest (read-only or limited access). Map each role to a set of permissions like manage_billing, invite_members, delete_data, view_analytics.

// Permission definitions
const PERMISSIONS = {
  // Billing & Plan
  MANAGE_BILLING: 'manage_billing',
  VIEW_BILLING: 'view_billing',

  // Members & Roles
  INVITE_MEMBERS: 'invite_members',
  REMOVE_MEMBERS: 'remove_members',
  MANAGE_ROLES: 'manage_roles',

  // Organization Settings
  UPDATE_ORG_SETTINGS: 'update_org_settings',
  DELETE_ORGANIZATION: 'delete_organization',

  // Data & Content
  CREATE_CONTENT: 'create_content',
  EDIT_OWN_CONTENT: 'edit_own_content',
  EDIT_ALL_CONTENT: 'edit_all_content',
  DELETE_CONTENT: 'delete_content',
  VIEW_CONTENT: 'view_content',

  // Analytics & Reports
  VIEW_ANALYTICS: 'view_analytics',
  EXPORT_DATA: 'export_data'
};

// Role to permission mapping
const ROLE_PERMISSIONS = {
  owner: [
    PERMISSIONS.MANAGE_BILLING,
    PERMISSIONS.VIEW_BILLING,
    PERMISSIONS.INVITE_MEMBERS,
    PERMISSIONS.REMOVE_MEMBERS,
    PERMISSIONS.MANAGE_ROLES,
    PERMISSIONS.UPDATE_ORG_SETTINGS,
    PERMISSIONS.DELETE_ORGANIZATION,
    PERMISSIONS.CREATE_CONTENT,
    PERMISSIONS.EDIT_OWN_CONTENT,
    PERMISSIONS.EDIT_ALL_CONTENT,
    PERMISSIONS.DELETE_CONTENT,
    PERMISSIONS.VIEW_CONTENT,
    PERMISSIONS.VIEW_ANALYTICS,
    PERMISSIONS.EXPORT_DATA
  ],
  admin: [
    PERMISSIONS.VIEW_BILLING,
    PERMISSIONS.INVITE_MEMBERS,
    PERMISSIONS.REMOVE_MEMBERS,
    PERMISSIONS.UPDATE_ORG_SETTINGS,
    PERMISSIONS.CREATE_CONTENT,
    PERMISSIONS.EDIT_OWN_CONTENT,
    PERMISSIONS.EDIT_ALL_CONTENT,
    PERMISSIONS.DELETE_CONTENT,
    PERMISSIONS.VIEW_CONTENT,
    PERMISSIONS.VIEW_ANALYTICS,
    PERMISSIONS.EXPORT_DATA
  ],
  member: [
    PERMISSIONS.CREATE_CONTENT,
    PERMISSIONS.EDIT_OWN_CONTENT,
    PERMISSIONS.VIEW_CONTENT
  ],
  guest: [
    PERMISSIONS.VIEW_CONTENT
  ]
};

// Permission checking utility
class PermissionService {
  hasPermission(role, permission) {
    const rolePermissions = ROLE_PERMISSIONS[role] || [];
    return rolePermissions.includes(permission);
  }

  async canUserPerformAction(userId, organizationId, permission) {
    const membership = await db.query(
      'SELECT role FROM organization_members WHERE user_id = $1 AND organization_id = $2',
      [userId, organizationId]
    );

    if (!membership.rows[0]) {
      return false; // User not a member
    }

    return this.hasPermission(membership.rows[0].role, permission);
  }
}

This permission system centralizes authorization logic. Every controller action checks permissions through the same service, making it easy to audit what each role can do. Changing role capabilities requires updating only the ROLE_PERMISSIONS mapping, not scattered permission checks across your codebase.

Implement permission checks as middleware in your API routes. This prevents authorization bypasses where developers forget to add permission checks to sensitive endpoints. The middleware loads the user's membership for the requested organization and verifies required permissions before passing the request to controller logic.

// Express.js permission middleware
function requirePermission(permission) {
  return async (req, res, next) => {
    const userId = req.user.id;
    const organizationId = req.params.organizationId || req.body.organizationId;

    if (!organizationId) {
      return res.status(400).json({ error: 'Organization ID required' });
    }

    const hasPermission = await permissionService.canUserPerformAction(
      userId,
      organizationId,
      permission
    );

    if (!hasPermission) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: permission
      });
    }

    // Attach membership to request for later use
    req.membership = await getMembership(userId, organizationId);
    next();
  };
}

// Usage in routes
app.delete(
  '/api/organizations/:organizationId/members/:memberId',
  authenticate,
  requirePermission(PERMISSIONS.REMOVE_MEMBERS),
  async (req, res) => {
    // Controller logic here - permission already verified
    await removeMember(req.params.memberId);
    res.json({ success: true });
  }
);

This pattern makes permission requirements explicit in route definitions. Anyone reading the code can immediately see what permissions are needed for each action. It also prevents the common bug where permission checks exist but aren't actually enforced because a developer forgot to call the check function.

Invitation Workflow and Email Verification

Users join organizations through two paths: the creator automatically becomes the owner when creating an organization, and subsequent members join via invitation. The invitation workflow requires careful design to handle edge cases: inviting existing users versus new users, preventing invitation spam, expiring old invitations, and tracking who invited whom.

The invitation table stores pending invitations with an expiration date and a secure token. When an admin invites someone, the system creates an invitation record and sends an email with a unique link. Clicking the link either adds an existing user to the organization or prompts a new user to sign up, then automatically adds them upon account creation.

-- Invitation schema
CREATE TABLE organization_invitations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  email VARCHAR(255) NOT NULL,
  role VARCHAR(50) NOT NULL DEFAULT 'member',
  invited_by UUID NOT NULL REFERENCES users(id),
  token VARCHAR(255) NOT NULL UNIQUE,
  expires_at TIMESTAMP NOT NULL,
  accepted_at TIMESTAMP,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE(organization_id, email)
);

CREATE INDEX idx_invitations_token ON organization_invitations(token);
CREATE INDEX idx_invitations_email ON organization_invitations(email);
CREATE INDEX idx_invitations_org ON organization_invitations(organization_id);

The unique constraint on (organization_id, email) prevents duplicate invitations to the same person. The token is a cryptographically secure random string—use crypto.randomBytes(32).toString('hex') in Node.js, not Math.random(). The expiration date defaults to 7 days, balancing security (tokens don't last forever) with user experience (people have time to respond).

// Invitation workflow implementation
class InvitationService {
  async inviteUser(organizationId, email, role, invitedBy) {
    // Check if user is already a member
    const existingMember = await db.query(
      'SELECT 1 FROM organization_members om JOIN users u ON om.user_id = u.id WHERE om.organization_id = $1 AND u.email = $2',
      [organizationId, email]
    );

    if (existingMember.rows.length > 0) {
      throw new Error('User is already a member of this organization');
    }

    // Check seat limits
    const org = await getOrganization(organizationId);
    const currentMemberCount = await getMemberCount(organizationId);
    const pendingInvitations = await getPendingInvitationCount(organizationId);

    if (currentMemberCount + pendingInvitations >= org.max_seats) {
      throw new Error('Organization has reached maximum seat limit');
    }

    // Create or update invitation
    const token = crypto.randomBytes(32).toString('hex');
    const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

    await db.query(
      `INSERT INTO organization_invitations (organization_id, email, role, invited_by, token, expires_at)
       VALUES ($1, $2, $3, $4, $5, $6)
       ON CONFLICT (organization_id, email)
       DO UPDATE SET token = $5, expires_at = $6, invited_by = $4, role = $3, created_at = NOW()`,
      [organizationId, email, role, invitedBy, token, expiresAt]
    );

    // Send invitation email
    await this.sendInvitationEmail(email, organizationId, token);

    return { token, expiresAt };
  }

  async acceptInvitation(token) {
    const invitation = await db.query(
      'SELECT * FROM organization_invitations WHERE token = $1 AND accepted_at IS NULL',
      [token]
    );

    if (!invitation.rows[0]) {
      throw new Error('Invitation not found or already accepted');
    }

    const inv = invitation.rows[0];

    if (new Date() > inv.expires_at) {
      throw new Error('Invitation has expired');
    }

    // Find or prompt for user creation
    let user = await getUserByEmail(inv.email);

    if (!user) {
      // Return invitation details to prompt signup
      return {
        requiresSignup: true,
        email: inv.email,
        organizationName: await getOrganizationName(inv.organization_id),
        token
      };
    }

    // Add user to organization
    await db.query('BEGIN');

    try {
      await db.query(
        'INSERT INTO organization_members (organization_id, user_id, role, invited_by) VALUES ($1, $2, $3, $4)',
        [inv.organization_id, user.id, inv.role, inv.invited_by]
      );

      await db.query(
        'UPDATE organization_invitations SET accepted_at = NOW() WHERE id = $1',
        [inv.id]
      );

      await db.query('COMMIT');

      return {
        success: true,
        organizationId: inv.organization_id,
        role: inv.role
      };
    } catch (error) {
      await db.query('ROLLBACK');
      throw error;
    }
  }

  async sendInvitationEmail(email, organizationId, token) {
    const org = await getOrganization(organizationId);
    const inviteUrl = `${process.env.APP_URL}/invitations/accept?token=${token}`;

    await emailService.send({
      to: email,
      subject: `You've been invited to join ${org.name}`,
      html: `
        

You've been invited to join ${org.name}.

Click here to accept the invitation

This invitation expires in 7 days.

` }); } }

This implementation handles the complete invitation lifecycle. The UPSERT pattern (INSERT ... ON CONFLICT DO UPDATE) lets admins resend invitations by re-inviting the same email—it updates the token and expiration instead of failing. The seat limit check prevents inviting more users than the organization's plan allows. The transaction ensures adding members and marking invitations as accepted happens atomically.

One important UX consideration: when a new user signs up via invitation, automatically log them in after account creation and redirect them to the organization. Don't make them sign up, verify email, then find and click the invitation link again. Store the invitation token in the signup flow and complete acceptance immediately after account creation.

Warning: Always verify that the invited email matches the signing-up user's email when accepting invitations. Otherwise, a user could intercept an invitation token and accept it with a different email address, gaining unauthorized access to the organization.

Organization Switching and Context Management

Users who belong to multiple organizations need a way to switch between them. The UX pattern is an organization switcher in your application header showing the current organization with a dropdown listing all accessible organizations. Behind this UX is context management: tracking which organization the user is currently acting within.

The naive implementation stores current organization in session state server-side. This works but creates problems with multiple browser tabs—switching organizations in one tab affects all other tabs. The better approach stores organization context in the URL (yourapp.com/org/acme-corp/dashboard) or as a subdomain (acme-corp.yourapp.com). This makes context explicit and allows different tabs to work with different organizations simultaneously.

// Organization context middleware
async function loadOrganizationContext(req, res, next) {
  const userId = req.user.id;
  let organizationId = req.params.organizationSlug || req.subdomains[0];

  // If using subdomain, look up org by subdomain
  if (req.subdomains.length > 0) {
    const org = await db.query(
      'SELECT id FROM organizations WHERE slug = $1',
      [req.subdomains[0]]
    );

    if (!org.rows[0]) {
      return res.status(404).json({ error: 'Organization not found' });
    }

    organizationId = org.rows[0].id;
  }

  // Verify user is a member
  const membership = await db.query(
    'SELECT om.*, o.* FROM organization_members om JOIN organizations o ON om.organization_id = o.id WHERE om.user_id = $1 AND o.id = $2',
    [userId, organizationId]
  );

  if (!membership.rows[0]) {
    return res.status(403).json({ error: 'Access denied to this organization' });
  }

  // Attach to request
  req.organization = {
    id: membership.rows[0].organization_id,
    name: membership.rows[0].name,
    slug: membership.rows[0].slug,
    plan: membership.rows[0].plan
  };

  req.membership = {
    role: membership.rows[0].role,
    joinedAt: membership.rows[0].joined_at
  };

  next();
}

// Usage
app.get(
  '/api/organizations/:organizationSlug/projects',
  authenticate,
  loadOrganizationContext,
  async (req, res) => {
    // req.organization and req.membership are available
    const projects = await getProjects(req.organization.id);
    res.json(projects);
  }
);

This middleware loads organization context on every request, verifying the user has access and attaching both organization and membership details to the request object. Controllers never directly query for organization or membership—the middleware guarantees these are available and valid.

For the frontend, maintain a list of the user's organizations in memory (loaded once at login) and current organization in application state. When switching organizations, navigate to the new organization's URL. This keeps context management simple and makes the active organization obvious from the URL.

// Frontend organization context
class OrganizationContext {
  constructor() {
    this.organizations = [];
    this.currentOrganization = null;
  }

  async loadOrganizations() {
    const response = await fetch('/api/user/organizations');
    this.organizations = await response.json();

    // Set current based on URL
    const slug = window.location.pathname.split('/')[2];
    this.currentOrganization = this.organizations.find(o => o.slug === slug);
  }

  switchOrganization(slug) {
    window.location.href = `/org/${slug}/dashboard`;
  }

  async createOrganization(name) {
    const response = await fetch('/api/organizations', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name })
    });

    const org = await response.json();
    this.organizations.push(org);
    this.switchOrganization(org.slug);
  }
}

This approach scales naturally. Users with access to 2 organizations or 200 organizations use the same switcher UI. The current organization is always unambiguous. API requests include organization context in the URL, making logs and analytics easy to interpret.

Custom Roles and Fine-Grained Permissions

The four standard roles (owner, admin, member, guest) work for most SaaS products, but some applications need custom roles. A project management tool might need "viewer" and "editor" roles per project. A CRM might need "sales rep," "sales manager," and "account executive" with different data access patterns. Custom roles require a more flexible permission system.

Instead of hardcoding role-permission mappings, store them in the database. Create a roles table defining available roles per organization, and a role_permissions table mapping roles to permissions. This lets organizations create custom roles tailored to their needs.

-- Custom roles schema
CREATE TABLE roles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  name VARCHAR(100) NOT NULL,
  description TEXT,
  is_default BOOLEAN NOT NULL DEFAULT false, -- System default roles
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  UNIQUE(organization_id, name)
);

CREATE TABLE role_permissions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
  permission VARCHAR(100) NOT NULL,
  UNIQUE(role_id, permission)
);

-- Update memberships to reference roles table
ALTER TABLE organization_members
DROP COLUMN role,
ADD COLUMN role_id UUID REFERENCES roles(id);

When creating an organization, seed it with default roles (owner, admin, member, guest) by copying from a template. Organizations can then modify these defaults or create entirely new roles. The is_default flag distinguishes system roles from custom ones—system roles might have special UI treatment or restrictions on modification.

// Custom roles implementation
class RoleService {
  async createOrganizationWithDefaultRoles(name, ownerId) {
    await db.query('BEGIN');

    try {
      // Create organization
      const orgResult = await db.query(
        'INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING *',
        [name, slugify(name)]
      );
      const organization = orgResult.rows[0];

      // Create default roles
      const ownerRole = await this.createRole(organization.id, 'Owner', 'Full access including billing', true);
      const adminRole = await this.createRole(organization.id, 'Admin', 'Manage members and settings', true);
      const memberRole = await this.createRole(organization.id, 'Member', 'Standard user access', true);

      // Assign permissions to roles
      await this.assignPermissionsToRole(ownerRole.id, ROLE_PERMISSIONS.owner);
      await this.assignPermissionsToRole(adminRole.id, ROLE_PERMISSIONS.admin);
      await this.assignPermissionsToRole(memberRole.id, ROLE_PERMISSIONS.member);

      // Add owner as first member
      await db.query(
        'INSERT INTO organization_members (organization_id, user_id, role_id) VALUES ($1, $2, $3)',
        [organization.id, ownerId, ownerRole.id]
      );

      await db.query('COMMIT');
      return organization;
    } catch (error) {
      await db.query('ROLLBACK');
      throw error;
    }
  }

  async createRole(organizationId, name, description, isDefault = false) {
    const result = await db.query(
      'INSERT INTO roles (organization_id, name, description, is_default) VALUES ($1, $2, $3, $4) RETURNING *',
      [organizationId, name, description, isDefault]
    );
    return result.rows[0];
  }

  async assignPermissionsToRole(roleId, permissions) {
    const values = permissions.map(p => `('${roleId}', '${p}')`).join(',');
    await db.query(
      `INSERT INTO role_permissions (role_id, permission) VALUES ${values} ON CONFLICT DO NOTHING`
    );
  }

  async hasPermission(userId, organizationId, permission) {
    const result = await db.query(
      `SELECT 1 FROM organization_members om
       JOIN role_permissions rp ON om.role_id = rp.role_id
       WHERE om.user_id = $1 AND om.organization_id = $2 AND rp.permission = $3`,
      [userId, organizationId, permission]
    );

    return result.rows.length > 0;
  }
}

This flexible system lets you start with standard roles and add custom roles as your product matures. Enterprise customers often request custom roles as part of sales negotiations—the schema supports this without code changes. The permission checking logic remains simple: join memberships to role_permissions to see if the user has the required permission.

Role Type Flexibility Complexity Best For
Hardcoded Roles Low Low Simple SaaS with straightforward needs
Fixed Set with Config Medium Medium Most B2B SaaS applications
Custom Roles per Org High High Enterprise SaaS with complex hierarchies
Attribute-Based (ABAC) Very High Very High Security-critical apps, compliance needs

Seat Management and License Enforcement

B2B SaaS pricing typically charges per seat—each team member consuming a license. Seat management requires tracking active members against plan limits, handling seat removal when members leave, and preventing organizations from exceeding limits through simultaneous invitations or member additions.

The implementation challenge is defining what counts as a seat. In most products, active members count regardless of role—owners, admins, and regular members all consume seats. Guests might not count, depending on your pricing model. Pending invitations should count to prevent organizations from inviting 50 people when they have 10 available seats.

// Seat management service
class SeatManagementService {
  async getSeatsInfo(organizationId) {
    const org = await getOrganization(organizationId);

    const activeMembers = await db.query(
      'SELECT COUNT(*) FROM organization_members WHERE organization_id = $1',
      [organizationId]
    );

    const pendingInvitations = await db.query(
      'SELECT COUNT(*) FROM organization_invitations WHERE organization_id = $1 AND accepted_at IS NULL AND expires_at > NOW()',
      [organizationId]
    );

    const total = parseInt(activeMembers.rows[0].count) + parseInt(pendingInvitations.rows[0].count);

    return {
      maxSeats: org.max_seats,
      usedSeats: total,
      activeMembers: parseInt(activeMembers.rows[0].count),
      pendingInvitations: parseInt(pendingInvitations.rows[0].count),
      availableSeats: Math.max(0, org.max_seats - total)
    };
  }

  async canAddMember(organizationId, count = 1) {
    const seatsInfo = await this.getSeatsInfo(organizationId);
    return seatsInfo.availableSeats >= count;
  }

  async removeMember(memberId, removedBy) {
    const member = await db.query(
      'SELECT * FROM organization_members WHERE id = $1',
      [memberId]
    );

    if (!member.rows[0]) {
      throw new Error('Member not found');
    }

    const m = member.rows[0];

    // Prevent removing the last owner
    const ownerCount = await db.query(
      'SELECT COUNT(*) FROM organization_members om JOIN roles r ON om.role_id = r.id WHERE om.organization_id = $1 AND r.name = $2',
      [m.organization_id, 'Owner']
    );

    if (parseInt(ownerCount.rows[0].count) === 1) {
      const isOwner = await db.query(
        'SELECT 1 FROM organization_members om JOIN roles r ON om.role_id = r.id WHERE om.id = $1 AND r.name = $2',
        [memberId, 'Owner']
      );

      if (isOwner.rows[0]) {
        throw new Error('Cannot remove the last owner. Transfer ownership first.');
      }
    }

    // Log the removal
    await db.query(
      'INSERT INTO organization_audit_log (organization_id, user_id, action, details) VALUES ($1, $2, $3, $4)',
      [m.organization_id, removedBy, 'member_removed', JSON.stringify({ removedUserId: m.user_id })]
    );

    // Remove member
    await db.query('DELETE FROM organization_members WHERE id = $1', [memberId]);

    return { success: true };
  }

  async updatePlan(organizationId, newPlan, newMaxSeats) {
    const seatsInfo = await this.getSeatsInfo(organizationId);

    if (newMaxSeats < seatsInfo.activeMembers) {
      throw new Error(`Cannot reduce seats below current member count (${seatsInfo.activeMembers})`);
    }

    await db.query(
      'UPDATE organizations SET plan = $1, max_seats = $2, updated_at = NOW() WHERE id = $3',
      [newPlan, newMaxSeats, organizationId]
    );

    return { success: true };
  }
}

The seat counting logic includes both active members and pending invitations to prevent over-invitation. The canAddMember check runs before creating invitations, before accepting invitations, and before any other operation that increases member count. The last-owner protection prevents organizations from accidentally removing all administrators.

When organizations downgrade plans, handle seat reduction carefully. The updatePlan function prevents reducing max_seats below current active members—this would create an invalid state. Instead, require organizations to remove members first, then downgrade. Alternatively, allow the downgrade but mark the organization as over-limit and prevent new invitations until they remove members.

Pro Tip: Display seat usage prominently in your admin UI. Show "8 / 10 seats used" near the invite button so admins know when they're approaching limits. Include a clear upgrade CTA when they hit the limit—this is a high-intent upgrade moment where users are actively trying to add team members.

Audit Logging for Team Actions

Team management requires comprehensive audit logging for security, compliance, and debugging. Track who invited whom, who changed roles, who removed members, and who modified organization settings. During security incidents or compliance audits, these logs provide the paper trail needed to understand what happened and when.

The audit log schema stores actor (who performed the action), action type, affected resources, old and new values, and IP address or other context. Retention policies depend on compliance requirements—some industries require 7 years of audit logs, others 90 days.

-- Audit log schema
CREATE TABLE organization_audit_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  user_id UUID NOT NULL REFERENCES users(id),
  action VARCHAR(100) NOT NULL, -- 'member_invited', 'member_removed', 'role_changed', etc
  resource_type VARCHAR(50), -- 'member', 'invitation', 'organization', 'role'
  resource_id UUID,
  old_values JSONB,
  new_values JSONB,
  ip_address INET,
  user_agent TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_audit_log_org ON organization_audit_log(organization_id, created_at DESC);
CREATE INDEX idx_audit_log_user ON organization_audit_log(user_id, created_at DESC);
CREATE INDEX idx_audit_log_action ON organization_audit_log(organization_id, action, created_at DESC);

Log audit events immediately after successful actions, not before. This prevents logs for actions that failed validation or database constraints. Store enough context to reconstruct what happened without querying other tables—include user email, not just user ID, so logs remain interpretable even if the user account is later deleted.

// Audit logging utility
class AuditLogger {
  async log(organizationId, userId, action, details = {}) {
    const context = {
      ip: details.ip || null,
      userAgent: details.userAgent || null
    };

    await db.query(
      `INSERT INTO organization_audit_log
       (organization_id, user_id, action, resource_type, resource_id, old_values, new_values, ip_address, user_agent)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
      [
        organizationId,
        userId,
        action,
        details.resourceType || null,
        details.resourceId || null,
        details.oldValues ? JSON.stringify(details.oldValues) : null,
        details.newValues ? JSON.stringify(details.newValues) : null,
        context.ip,
        context.userAgent
      ]
    );
  }

  async logMemberInvited(organizationId, invitedBy, email, role) {
    await this.log(organizationId, invitedBy, 'member_invited', {
      resourceType: 'invitation',
      newValues: { email, role }
    });
  }

  async logRoleChanged(organizationId, changedBy, memberId, oldRole, newRole) {
    await this.log(organizationId, changedBy, 'role_changed', {
      resourceType: 'member',
      resourceId: memberId,
      oldValues: { role: oldRole },
      newValues: { role: newRole }
    });
  }

  async getAuditLog(organizationId, options = {}) {
    const { limit = 100, offset = 0, action = null, userId = null } = options;

    let query = `
      SELECT al.*, u.email as user_email, u.name as user_name
      FROM organization_audit_log al
      JOIN users u ON al.user_id = u.id
      WHERE al.organization_id = $1
    `;

    const params = [organizationId];
    let paramIndex = 2;

    if (action) {
      query += ` AND al.action = $${paramIndex}`;
      params.push(action);
      paramIndex++;
    }

    if (userId) {
      query += ` AND al.user_id = $${paramIndex}`;
      params.push(userId);
      paramIndex++;
    }

    query += ` ORDER BY al.created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
    params.push(limit, offset);

    const result = await db.query(query, params);
    return result.rows;
  }
}

The audit log becomes a product feature, not just internal tooling. Expose it in the admin UI as an "Activity Log" showing recent team actions. Security-conscious customers review these logs regularly. During support conversations, audit logs help troubleshoot "who changed this setting" or "when did this user join" questions instantly.

Ownership Transfer and Organization Deletion

Two critical edge cases require special handling: transferring organization ownership and deleting organizations. Ownership transfer happens when the original owner leaves a company or wants to hand off control. Organization deletion must be carefully controlled to prevent accidental data loss.

Ownership transfer requires confirmation from both parties in enterprise contexts, or at minimum from the current owner. The new owner must already be a member of the organization. After transfer, the old owner typically becomes an admin rather than being removed, preserving their access while transferring control.

// Ownership transfer
class OrganizationService {
  async transferOwnership(organizationId, currentOwnerId, newOwnerId, confirmedBy) {
    // Verify current owner
    const currentOwner = await db.query(
      'SELECT om.* FROM organization_members om JOIN roles r ON om.role_id = r.id WHERE om.organization_id = $1 AND om.user_id = $2 AND r.name = $3',
      [organizationId, currentOwnerId, 'Owner']
    );

    if (!currentOwner.rows[0]) {
      throw new Error('Current user is not the owner');
    }

    // Verify new owner is a member
    const newOwner = await db.query(
      'SELECT * FROM organization_members WHERE organization_id = $1 AND user_id = $2',
      [organizationId, newOwnerId]
    );

    if (!newOwner.rows[0]) {
      throw new Error('New owner must be a member of the organization');
    }

    // Get role IDs
    const roles = await db.query(
      'SELECT id, name FROM roles WHERE organization_id = $1 AND name IN ($2, $3)',
      [organizationId, 'Owner', 'Admin']
    );

    const ownerRoleId = roles.rows.find(r => r.name === 'Owner').id;
    const adminRoleId = roles.rows.find(r => r.name === 'Admin').id;

    await db.query('BEGIN');

    try {
      // Change current owner to admin
      await db.query(
        'UPDATE organization_members SET role_id = $1 WHERE id = $2',
        [adminRoleId, currentOwner.rows[0].id]
      );

      // Change new member to owner
      await db.query(
        'UPDATE organization_members SET role_id = $1 WHERE id = $2',
        [ownerRoleId, newOwner.rows[0].id]
      );

      // Log the transfer
      await auditLogger.log(organizationId, confirmedBy, 'ownership_transferred', {
        resourceType: 'organization',
        resourceId: organizationId,
        oldValues: { ownerId: currentOwnerId },
        newValues: { ownerId: newOwnerId }
      });

      await db.query('COMMIT');
      return { success: true };
    } catch (error) {
      await db.query('ROLLBACK');
      throw error;
    }
  }

  async deleteOrganization(organizationId, userId, confirmation) {
    // Verify user is owner
    const owner = await db.query(
      'SELECT 1 FROM organization_members om JOIN roles r ON om.role_id = r.id WHERE om.organization_id = $1 AND om.user_id = $2 AND r.name = $3',
      [organizationId, userId, 'Owner']
    );

    if (!owner.rows[0]) {
      throw new Error('Only owners can delete organizations');
    }

    const org = await getOrganization(organizationId);

    // Require exact name match as confirmation
    if (confirmation !== org.name) {
      throw new Error('Organization name confirmation does not match');
    }

    // Log before deletion
    await auditLogger.log(organizationId, userId, 'organization_deleted', {
      resourceType: 'organization',
      resourceId: organizationId,
      oldValues: { name: org.name, slug: org.slug }
    });

    // Soft delete (recommended) or hard delete
    await db.query(
      'UPDATE organizations SET deleted_at = NOW() WHERE id = $1',
      [organizationId]
    );

    // Or hard delete: await db.query('DELETE FROM organizations WHERE id = $1', [organizationId]);

    return { success: true };
  }
}

The deletion confirmation requiring exact organization name typing prevents accidental deletions. For extra safety, implement soft deletion where organizations are marked deleted but data remains in the database for 30 days. This allows recovering from accidental deletions while eventually purging data for storage management.

Frequently Asked Questions

Should I allow users to belong to multiple organizations?

Yes, unless you have a specific reason not to. Most B2B SaaS users work for multiple companies (contractors, agencies) or manage multiple projects. The added complexity of multi-organization support is minimal—it's just a many-to-many relationship via the memberships table. The UX improvement is significant, preventing users from creating multiple accounts.

How do I handle organization billing when members join or leave?

Most SaaS products use monthly billing with immediate seat charges. When a member joins, charge for the additional seat prorated for the remainder of the billing period. When a member leaves, credit the unused portion or bank it toward the next invoice. Alternatively, use consumption-based billing that charges for peak seats used during the billing period, simplifying the accounting.

What happens to data when a user leaves an organization?

Data created by users should remain with the organization, not the user. When a user leaves, transfer ownership of their created content to the organization, to the team owner, or mark it as created by a former member. Never delete user-created data when they leave—it belongs to the organization that paid for the service during content creation.

How do I prevent invitation spam?

Implement rate limiting on invitation sending (e.g., 10 invitations per hour per organization). Require email verification before users can send invitations. Track invitation acceptance rates per organization and flag organizations with low acceptance as potential abusers. Add CAPTCHA to invitation forms if spam becomes a problem.

Should I allow multiple owners per organization?

This depends on your product. Multiple owners simplify scenarios where cofounders share control, but they complicate billing (who gets charged?) and cancellation (do all owners need to approve?). A middle ground is the owner role for billing control plus admin roles with nearly equivalent permissions. Make the distinction clear: owner controls billing and final decisions, admins manage the product.

How granular should permissions be?

Start with 10-20 coarse-grained permissions like "manage_billing," "invite_members," "delete_content." Don't create permissions like "edit_project_title" or "view_user_email"—this makes the system unmaintainable. Add granularity only when customers explicitly request it, usually as part of enterprise deals. Most B2B SaaS products never need more than 30 total permissions.

How do I handle users who are members of 100+ organizations?

This is rare but happens with agencies or power users. Implement pagination in the organization list, and add search/filter capabilities. Allow pinning frequently-used organizations to the top of the list. Consider a recently-accessed organizations section showing the last 5-10 organizations the user worked with. Cache the organization list in the client to avoid repeated API calls.

What's the best way to structure organization URLs?

Three options exist: path-based (app.com/org/acme-corp), subdomain (acme-corp.app.com), or query parameter (app.com?org=acme-corp). Path-based is simplest to implement and works well for most products. Subdomain-based provides the strongest isolation and is preferred for white-label scenarios. Query parameter is the weakest—avoid it as it makes context ambiguous and breaks browser back button expectations.

Conclusion

Team and organization management forms the foundation of B2B SaaS applications. The core data model—separating users, organizations, and memberships—enables flexible access patterns where users belong to multiple organizations with different roles in each. Role-based access control centralizes permission logic, making authorization decisions consistent and auditable across your application.

The implementation patterns in this article—invitation workflows, seat management, audit logging, and organization context handling—solve the common problems that every multi-tenant B2B SaaS faces. Start with standard roles and hardcoded permissions, then add custom roles when enterprise customers require it. Implement seat counting and enforcement early to prevent billing mismatches and support escalations.

Team management is not just plumbing—it's a core product feature that impacts user onboarding, security posture, and revenue through seat-based billing. Invest in building it properly rather than retrofitting team functionality into a single-user application. The patterns here scale from solo founders to organizations with thousands of members without requiring architectural changes.


Share on Social Media: