Honestly, I just wanted a nice rainbow gradient banner at the top.

Building a multi-tenant organization system with Supabase part 3

thedevdavid
on July 19, 20251 min. read

Introduction

In Part 2, we built the complete database schema for our multi-tenant system. Now we'll implement the authorization layer that makes it secure and functional:

  • JWT Claims - Custom auth hooks that inject tenant context
  • Permission System - Account-based authorization checks
  • Row Level Security - Database-level access control policies

Step 1: Postgres Triggers

Using postgres triggers and functions, let's make sure that all users have a personal account created for them

-- Function to handle new user registration
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
DECLARE
  personal_account_id uuid;
  username text;
  unique_username text;
BEGIN
  -- Extract username from metadata or email
  username := COALESCE(NEW.raw_user_meta_data->>'username', split_part(NEW.email, '@', 1));

  -- Generate unique username
  unique_username := username;
  WHILE EXISTS (SELECT 1 FROM public.user_profiles WHERE username = unique_username) LOOP
    unique_username := username || '_' || substr(gen_random_uuid()::text, 1, 4);
  END LOOP;

  -- Create user profile
  INSERT INTO public.user_profiles (id, email, username, email_status)
  VALUES (
    NEW.id,
    NEW.email,
    unique_username,
    CASE WHEN NEW.email_confirmed_at IS NULL THEN 'pending' ELSE 'confirmed' END
  );

  -- Create personal account with default client app role
  INSERT INTO accounts.accounts (name, owner_id, type, slug, status, app_role)
  SELECT
    unique_username,
    NEW.id,
    act.id,
    unique_username,
    acs.id,
    aar.id
  FROM accounts.account_types act,
       accounts.account_statuses acs,
       accounts.account_app_roles aar
  WHERE act.slug = 'personal'
    AND acs.slug = 'active'
    AND aar.slug = 'client'
  RETURNING id INTO personal_account_id;

  -- Add owner membership
  INSERT INTO accounts.account_members (account_id, user_id, role_id, membership_status)
  SELECT personal_account_id, NEW.id, ar.id, acs.id
  FROM accounts.account_roles ar, accounts.account_statuses acs
  WHERE ar.slug = 'owner' AND acs.slug = 'active';

  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Create the trigger
CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

Step 2: Configure Auth Claims Hook

In your Supabase dashboard, go to Authentication > Hooks and set up the custom access token hook. So we can use Supabase Auth Hooks to inject the user's account memberships and roles into the claims object:

CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
  claims jsonb;
  user_accounts jsonb;
BEGIN
  -- Get user's account memberships
  SELECT jsonb_object_agg(
    am.account_id::text,
    jsonb_build_object(
      'role', acr.slug,
      'account_name', a.name,
      'account_slug', a.slug,
      'status', ams.slug
    )
  ) INTO user_accounts
  FROM accounts.account_members am
  JOIN accounts.accounts a ON am.account_id = a.id
  JOIN accounts.account_roles acr ON am.role_id = acr.id
  JOIN accounts.account_statuses ams ON am.membership_status = ams.id
  WHERE am.user_id = (event->>'user_id')::uuid
  AND ams.slug = 'active';

  claims := event->'claims';

  -- Add custom claims to app_metadata
  claims := jsonb_set(
    claims,
    '{app_metadata}',
    COALESCE(claims->'app_metadata', '{}') || jsonb_build_object(
      'accounts', COALESCE(user_accounts, '{}')
    )
  );

  RETURN jsonb_set(event, '{claims}', claims);
END;
$$;

-- Grant necessary permissions
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook TO supabase_auth_admin;

Step 3: Permission System

Account-Level Authorization

For checking permissions within a specific account:

CREATE OR REPLACE FUNCTION public.authorize_account(
  requested_permission text,
  account_id uuid
)
RETURNS boolean
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
  _user_id uuid;
  _jwt jsonb;
  _accounts jsonb;
  _account_role text;
  _account_app_role text;
  _app_permissions jsonb;
BEGIN
  _user_id := auth.uid();
  IF _user_id IS NULL OR account_id IS NULL THEN
    RETURN FALSE;
  END IF;

  _jwt := auth.jwt();
  _accounts := _jwt -> 'app_metadata' -> 'accounts';

  -- Get user's role and app role in this account
  _account_role := _accounts -> account_id::text ->> 'role';
  _account_app_role := _accounts -> account_id::text ->> 'app_role';
  _app_permissions := _accounts -> account_id::text -> 'app_permissions';

  -- Check account app-level permissions first
  IF _app_permissions ? requested_permission THEN
    RETURN TRUE;
  END IF;

  -- Check admin account privileges
  IF _account_app_role = 'admin' AND requested_permission LIKE 'app:%' THEN
    RETURN TRUE;
  END IF;

  -- Check account membership-level permissions
  CASE _account_role
    WHEN 'owner' THEN
      RETURN requested_permission IN ('account:read', 'account:write', 'account:admin', 'account:delete');
    WHEN 'admin' THEN
      RETURN requested_permission IN ('account:read', 'account:write', 'account:admin');
    WHEN 'member' THEN
      RETURN requested_permission IN ('account:read', 'account:write');
    WHEN 'guest' THEN
      RETURN requested_permission = 'account:read';
    ELSE
      RETURN FALSE;
  END CASE;

  RETURN FALSE;
EXCEPTION
  WHEN OTHERS THEN
    RAISE LOG 'Error in authorize_account function: %', SQLERRM;
    RETURN FALSE;
END;
$$;

App-Level Authorization

For checking app-wide permissions based on account app roles:

CREATE OR REPLACE FUNCTION public.authorize_app(requested_permission text)
RETURNS boolean
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
  _user_id uuid;
  _jwt jsonb;
  _accounts jsonb;
  _account_id text;
  _account_info jsonb;
  _app_permissions jsonb;
BEGIN
  _user_id := auth.uid();
  IF _user_id IS NULL THEN
    RETURN FALSE;
  END IF;

  _jwt := auth.jwt();
  _accounts := _jwt -> 'app_metadata' -> 'accounts';

  -- Check all user's accounts for the requested permission
  FOR _account_id IN SELECT jsonb_object_keys(_accounts) LOOP
    _account_info := _accounts -> _account_id;
    _app_permissions := _account_info -> 'app_permissions';

    -- Check if this account grants the requested permission
    IF _app_permissions ? requested_permission THEN
      RETURN TRUE;
    END IF;

    -- Special case: admin accounts get broad app permissions
    IF _account_info ->> 'app_role' = 'admin' AND requested_permission LIKE 'app:%' THEN
      RETURN TRUE;
    END IF;
  END LOOP;

  RETURN FALSE;
EXCEPTION
  WHEN OTHERS THEN
    RAISE LOG 'Error in authorize_app function: %', SQLERRM;
    RETURN FALSE;
END;
$$;

Membership Validation

Check if a user is a member of a specific account:

CREATE OR REPLACE FUNCTION public.is_account_member(account_id uuid)
RETURNS boolean
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
  _user_id uuid;
  _jwt jsonb;
  _accounts jsonb;
BEGIN
  _user_id := auth.uid();
  IF _user_id IS NULL OR account_id IS NULL THEN
    RETURN FALSE;
  END IF;

  _jwt := auth.jwt();
  _accounts := _jwt -> 'app_metadata' -> 'accounts';

  -- Check if account exists in user's accounts and is active
  RETURN (_accounts -> account_id::text ->> 'status') = 'active';
EXCEPTION
  WHEN OTHERS THEN
    RETURN FALSE;
END;
$$;

Account Role Check

Get the user's role in a specific account:

CREATE OR REPLACE FUNCTION public.get_account_role(account_id uuid)
RETURNS text
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
  _user_id uuid;
  _jwt jsonb;
  _accounts jsonb;
BEGIN
  _user_id := auth.uid();
  IF _user_id IS NULL OR account_id IS NULL THEN
    RETURN null;
  END IF;

  _jwt := auth.jwt();
  _accounts := _jwt -> 'app_metadata' -> 'accounts';

  -- Return user's role in this account
  RETURN _accounts -> account_id::text ->> 'role';
EXCEPTION
  WHEN OTHERS THEN
    RETURN null;
END;
$$;

Account App Role Check

Get the app role of a specific account:

CREATE OR REPLACE FUNCTION public.get_account_app_role(account_id uuid)
RETURNS text
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
  _user_id uuid;
  _jwt jsonb;
  _accounts jsonb;
BEGIN
  _user_id := auth.uid();
  IF _user_id IS NULL OR account_id IS NULL THEN
    RETURN null;
  END IF;

  _jwt := auth.jwt();
  _accounts := _jwt -> 'app_metadata' -> 'accounts';

  -- Return account's app role
  RETURN _accounts -> account_id::text ->> 'app_role';
EXCEPTION
  WHEN OTHERS THEN
    RETURN null;
END;
$$;

Step 4: Example Row Level Security Policies

User Profiles Policies

-- Users can view their own profile
CREATE POLICY "Users can view own profile" ON public.user_profiles
  FOR SELECT TO authenticated
  USING (select auth.uid() = id);

-- Users can update their own profile
CREATE POLICY "Users can update own profile" ON public.user_profiles
  FOR UPDATE TO authenticated
  USING (select auth.uid() = id);

Account Access Control

-- Members can view accounts they belong to
CREATE POLICY "Members can view their accounts" ON accounts.accounts
  FOR SELECT TO authenticated
  USING (is_account_member(id) = TRUE);

-- Only owners and admins can update accounts
CREATE POLICY "Account owners and admins can update" ON accounts.accounts
  FOR UPDATE TO authenticated
  USING (authorize_account('account:admin', id) = TRUE);

-- Only owners can delete accounts
CREATE POLICY "Account owners can delete" ON accounts.accounts
  FOR DELETE TO authenticated
  USING (authorize_account('account:delete', id) = TRUE);

-- Authenticated users can create new accounts
CREATE POLICY "Users can create accounts" ON accounts.accounts
  FOR INSERT TO authenticated
  WITH CHECK (auth.uid() = owner_id);

Membership Management

-- Users can view memberships for accounts they belong to
CREATE POLICY "Users can view account memberships" ON accounts.account_members
  FOR SELECT TO authenticated
  USING (
    user_id = auth.uid()
    OR
    is_account_member(account_id) = TRUE
  );

-- Account owners and admins can manage memberships
CREATE POLICY "Account admins can manage memberships" ON accounts.account_members
  FOR INSERT TO authenticated
  WITH CHECK (
    authorize_account('account:admin', account_id) = TRUE
  );

CREATE POLICY "Account admins can update memberships" ON accounts.account_members
  FOR UPDATE TO authenticated
  USING (
    authorize_account('account:admin', account_id) = TRUE
  );

-- Only owners can delete memberships (or users leaving themselves)
CREATE POLICY "Account owners can delete memberships" ON accounts.account_members
  FOR DELETE TO authenticated
  USING (
    authorize_account('account:delete', account_id) = TRUE
    OR user_id = auth.uid()
  );

Domain Verification Policies

-- Account members can view domains
CREATE POLICY "Account members can view domains" ON public.domains
  FOR SELECT TO authenticated
  USING (
    is_account_member(account_id) = TRUE
  );

-- Account admins can manage domains
CREATE POLICY "Account admins can manage domains" ON public.domains
  FOR ALL TO authenticated
  USING (
    authorize_account('account:admin', account_id) = TRUE
  );

Step 5: Domain Management

Workspaces can add domains and verify domain ownership

CREATE TABLE public.domains(
  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
  account_id uuid REFERENCES accounts.accounts(id) ON DELETE CASCADE,
  name text NOT NULL CHECK (length(name) > 0 AND length(name) < 64),
  verification_code text,
  is_verified boolean DEFAULT FALSE,
  status uuid REFERENCES public.domain_statuses(id),
  updated_at timestamp with time zone DEFAULT now(),
  created_at timestamp with time zone DEFAULT now(),
  UNIQUE (account_id, name)
);

CREATE TABLE public.domain_statuses(
  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
  name text NOT NULL,
  slug text NOT NULL UNIQUE,
  description text
);

-- Insert domain statuses
INSERT INTO public.domain_statuses (name, slug, description) VALUES
  ('Pending', 'pending', 'Domain verification is pending'),
  ('Verified', 'verified', 'Domain has been verified'),
  ('Failed', 'failed', 'Domain verification failed'),
  ('Expired', 'expired', 'Domain verification has expired');

Verification Process

-- Generate verification code on insert
CREATE OR REPLACE FUNCTION private.set_verification_code()
RETURNS TRIGGER AS $$
DECLARE
  random_code text;
BEGIN
  random_code := private.generate_random_code(5);
  NEW.verification_code := encode(extensions.digest(random_code, 'sha256'), 'hex');
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Helper function to generate random codes
CREATE OR REPLACE FUNCTION private.generate_random_code(length int)
RETURNS text AS $$
BEGIN
  RETURN upper(substr(gen_random_uuid()::text, 1, length));
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Verify submitted code
CREATE OR REPLACE FUNCTION private.verify_code(domain_id uuid, code text)
RETURNS boolean AS $$
DECLARE
  hashed_code text;
  stored_code text;
BEGIN
  hashed_code := encode(extensions.digest(code, 'sha256'), 'hex');

  SELECT verification_code INTO stored_code
  FROM public.domains
  WHERE id = domain_id;

  RETURN hashed_code = stored_code;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Create trigger for verification code generation
CREATE TRIGGER on_domain_insert
  BEFORE INSERT ON public.domains
  FOR EACH ROW EXECUTE FUNCTION private.set_verification_code();

Step 6: Data Cleanup Functions

Implement regular maintenance procedures:

-- Clean up expired invitations
CREATE OR REPLACE FUNCTION accounts.cleanup_expired_invitations()
RETURNS void AS $$
BEGIN
  DELETE FROM accounts.account_members
  WHERE user_id IS NULL
  AND invite_expires_at < NOW() - INTERVAL '7 days';
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

-- Archive soft-deleted accounts
CREATE OR REPLACE FUNCTION accounts.archive_deleted_accounts()
RETURNS void AS $$
BEGIN
  UPDATE accounts.accounts
  SET deleted_at = NOW()
  WHERE status = (SELECT id FROM accounts.account_statuses WHERE slug = 'deleted')
  AND deleted_at IS NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Next: Building the Application

In Part 4, we'll implement the Next.js application:

  • Auth Context: React hooks for managing multi-tenant state
  • Permission Guards: Client-side components that respect account and app-level roles
  • Account Switcher: Seamless organization switching with app role awareness
  • Subdomain Routing: Automatic account context from URLs
  • Workspace Management: Add, remove, invite users to workspaces