DEV Community

kanta13jp1
kanta13jp1

Posted on

Supabase Multi-Tenant Design — RLS Tenant Isolation, Admin Roles, and Invite Flow

Supabase Multi-Tenant Design — RLS Tenant Isolation, Admin Roles, and Invite Flow

DB design patterns for SaaS apps with team and organization features.

Tenant (Organization) Schema

-- Organizations table
CREATE TABLE organizations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Membership
CREATE TABLE org_members (
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
  role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member')),
  joined_at TIMESTAMPTZ DEFAULT NOW(),
  PRIMARY KEY (org_id, user_id)
);

-- Data tables carry org_id
CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  name TEXT NOT NULL,
  created_by UUID REFERENCES profiles(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

RLS for Tenant Isolation

-- Only org members can view projects
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY "org members can view projects" ON projects
  FOR SELECT USING (
    org_id IN (
      SELECT org_id FROM org_members WHERE user_id = auth.uid()
    )
  );

-- Only admin+ can delete projects
CREATE POLICY "org admins can delete projects" ON projects
  FOR DELETE USING (
    EXISTS (
      SELECT 1 FROM org_members
      WHERE org_id = projects.org_id
        AND user_id = auth.uid()
        AND role IN ('owner', 'admin')
    )
  );
Enter fullscreen mode Exit fullscreen mode

Invite Flow

CREATE TABLE org_invitations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
  email TEXT NOT NULL,
  token TEXT UNIQUE DEFAULT gen_random_uuid()::TEXT,
  role TEXT DEFAULT 'member',
  expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days',
  accepted_at TIMESTAMPTZ
);
Enter fullscreen mode Exit fullscreen mode
// Edge Function: accept invitation
const { data: invite } = await supabase
  .from('org_invitations')
  .select('*')
  .eq('token', token)
  .gt('expires_at', new Date().toISOString())
  .is('accepted_at', null)
  .single();

if (!invite) throw new AppError('Invalid or expired invitation link', 'INVALID_INVITE', 400);

await supabase.from('org_members').insert({
  org_id: invite.org_id, user_id: userId, role: invite.role,
});
await supabase.from('org_invitations')
  .update({ accepted_at: new Date().toISOString() })
  .eq('id', invite.id);
Enter fullscreen mode Exit fullscreen mode

Summary

Tenant isolation  → RLS org_id condition (applied to every table)
Role hierarchy    → owner > admin > member (3 levels is enough)
Invite token      → UUID + 7-day expiry + used flag
Flutter side      → store current org_id in a Context/Provider
Enter fullscreen mode Exit fullscreen mode

Multi-tenancy must be designed in from the start. Retrofitting RLS risks data leaks.

Top comments (0)