Skip to main content

Authentication & Authorization

Client Portal uses Supabase Auth for identity management and PostgreSQL Row Level Security (RLS) for authorization. Authentication is email/password based. Authorization is role-based with three tiers.

Authentication

Supabase Auth handles all authentication flows:

  • Sign up -- Email + password, creates entry in auth.users
  • Sign in -- Returns a JWT containing the user's sub (user ID)
  • Password recovery -- Email-based reset flow
  • Session management -- JWTs are refreshed automatically by the Supabase client

The JWT is attached to every Supabase request (queries, mutations, edge function calls) and is used by RLS policies to determine access.

Role Model

Three roles exist, stored in the user_roles table:

RoleTypeDescription
adminInternalFull platform access. Can manage all clients, users, campaigns, and settings.
bdrInternalBusiness Development Representative. Access to assigned clients and all sales tables.
clientExternalClient user. Access restricted to their own organization's data only.

Role Hierarchy

Admins can do everything BDRs and clients can do. BDRs and clients have no overlap -- they access different parts of the application.

Permissions Matrix

ResourceAdminBDRClient
All clients listRead/WriteRead (assigned)--
Client dataRead/WriteRead/Write (assigned)Read (own)
CampaignsRead/WriteRead/Write (assigned)Read (own)
ContactsRead/WriteRead/Write (assigned)Read (own)
CallsRead/WriteRead/WriteRead (own)
MeetingsRead/WriteRead/WriteRead/Write (own)
MarketsRead/WriteReadRead (own)
SignalsRead/Write/AuthorizeReadRead (own)
AttorneysRead/WriteRead--
FirmsRead/WriteRead--
Sales prospectsRead/WriteRead/Write--
Sales callsRead/WriteRead/Write--
Users managementRead/Write----
Booking linksRead/WriteRead/WriteRead/Write (own)

RLS Helper Functions

Three SQL functions are used across all RLS policies:

has_role(role text)

Checks if the current authenticated user has the specified role.

CREATE OR REPLACE FUNCTION has_role(role text)
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid()
AND role = $1
);
$$ LANGUAGE sql SECURITY DEFINER STABLE;

get_user_client_id()

Returns the client_id from user_profiles for the current user. Returns NULL for internal users.

CREATE OR REPLACE FUNCTION get_user_client_id()
RETURNS uuid AS $$
SELECT client_id FROM user_profiles
WHERE user_id = auth.uid();
$$ LANGUAGE sql SECURITY DEFINER STABLE;

is_internal_user()

Returns true if the current user has either the admin or bdr role.

CREATE OR REPLACE FUNCTION is_internal_user()
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM user_roles
WHERE user_id = auth.uid()
AND role IN ('admin', 'bdr')
);
$$ LANGUAGE sql SECURITY DEFINER STABLE;
SECURITY DEFINER

All helper functions use SECURITY DEFINER so they can read user_roles and user_profiles regardless of the calling user's RLS permissions on those tables.

Frontend Route Protection

Route protection is implemented with three nested components:

ProtectedRoute

Ensures the user is authenticated. Redirects to /auth if not.

<ProtectedRoute>
{/* Only authenticated users reach here */}
</ProtectedRoute>

RoleGuard

Checks the user's role against an allowed list. Renders a "not authorized" message if the role does not match.

<RoleGuard allowedRoles={['admin', 'bdr']}>
{/* Only admin and bdr users reach here */}
</RoleGuard>

Specialized Guards

  • AdminGuard -- Shorthand for allowedRoles={['admin']}
  • WorkspaceGuard -- Ensures the user has a client_id and is accessing their own workspace

Route Structure

Auth Context Hook

The useAuth hook provides all authentication state and helpers:

import { useAuth } from '@/hooks/useAuth';

const {
user, // Supabase User object (id, email, etc.)
profile, // user_profiles row (display_name, client_id, timezone, etc.)
roles, // string[] of role names (e.g., ['admin'])
clientId, // UUID | null -- the user's client_id from user_profiles
isInternalUser, // boolean -- true if admin or bdr
signOut, // () => Promise<void>
hasRole, // (role: string) => boolean
} = useAuth();

Usage Examples

// Check if user is admin
if (hasRole('admin')) {
// Show admin-only UI
}

// Get client-scoped data
const { data } = useQuery({
queryKey: ['campaigns', clientId],
queryFn: () => supabase.from('campaigns')
.select('*')
.eq('client_id', clientId),
enabled: !!clientId,
});

// Conditional rendering based on role
{isInternalUser && <AdminSidebar />}

Security Notes

Important
  • RLS is enabled on every table. There are no tables without RLS.
  • The Supabase anon key is public (embedded in the frontend bundle). Security relies entirely on RLS policies.
  • Edge functions that accept external webhooks have verify_jwt = false in config.toml. They validate payloads through other means (e.g., checking external_call_id for deduplication).
  • The SUPABASE_SERVICE_ROLE_KEY is a secret and must never be exposed to the frontend. It bypasses all RLS and is used only in edge functions and server-side scripts.