# How to Build an User Permission Management with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a full RBAC system in Lovable with roles, permissions, and a role_permissions join table. A has_permission() SQL function enforces access at the database layer. The frontend shows a permission matrix with Checkbox toggles where admins assign permissions to roles in real time — no hardcoded role checks in application code.

## Before you start

- Lovable Pro account (multi-table schema generation requires credits)
- Supabase project with Auth enabled and at least one existing user
- SUPABASE_SERVICE_ROLE_KEY saved in Cloud tab → Secrets (required for admin user listing)
- Understanding of PostgreSQL RLS concepts — who can read and write which rows
- Familiarity with many-to-many database relationships (roles have many permissions via join table)

## Step-by-step guide

### 1. Create the RBAC schema

Prompt Lovable to create all four tables and the has_permission() SQL function. This is the foundation of the entire system — take extra care reviewing the generated SQL before applying it.

```
Create a complete RBAC schema in Supabase with these tables and function:

Tables:

1. roles:
   id uuid primary key default gen_random_uuid()
   name text not null unique (e.g. 'admin', 'editor', 'viewer')
   description text
   is_system boolean default false (system roles cannot be deleted)
   created_at timestamptz default now()

2. permissions:
   id uuid primary key default gen_random_uuid()
   name text not null unique (dot-notation: 'resource.action', e.g. 'reports.view', 'users.delete')
   description text
   resource text not null (the first part before the dot, for grouping)
   created_at timestamptz default now()

3. role_permissions:
   role_id uuid references roles(id) on delete cascade
   permission_id uuid references permissions(id) on delete cascade
   primary key (role_id, permission_id)
   granted_by uuid references auth.users(id)
   granted_at timestamptz default now()

4. user_roles:
   user_id uuid references auth.users(id) on delete cascade
   role_id uuid references roles(id) on delete cascade
   primary key (user_id, role_id)
   assigned_by uuid references auth.users(id)
   assigned_at timestamptz default now()

5. permission_audit_log:
   id uuid primary key default gen_random_uuid()
   action text not null (e.g. 'role.permission.granted', 'user.role.assigned')
   actor_id uuid references auth.users(id)
   target_role_id uuid references roles(id)
   target_user_id uuid references auth.users(id)
   permission_id uuid references permissions(id)
   metadata jsonb
   created_at timestamptz default now()

SQL Function:
CREATE OR REPLACE FUNCTION has_permission(p_user_id uuid, p_permission text)
RETURNS boolean
LANGUAGE sql
STABLE SECURITY DEFINER
AS $$
  SELECT EXISTS (
    SELECT 1
    FROM user_roles ur
    JOIN role_permissions rp ON rp.role_id = ur.role_id
    JOIN permissions p ON p.id = rp.permission_id
    WHERE ur.user_id = p_user_id
    AND p.name = p_permission
  );
$$;

RLS:
- roles: authenticated users can SELECT; only service role can INSERT/UPDATE/DELETE
- permissions: authenticated users can SELECT; only service role can INSERT/UPDATE/DELETE
- role_permissions: only users with 'admin.permissions.write' permission can INSERT/DELETE; authenticated SELECT
- user_roles: only users with 'admin.users.write' permission can INSERT/DELETE; authenticated SELECT
- permission_audit_log: INSERT allowed for authenticated; SELECT only for users with 'admin.audit.read'

Seed data: Insert roles 'admin', 'editor', 'viewer'. Insert permissions: 'reports.view', 'reports.export', 'users.view', 'users.write', 'users.delete', 'admin.permissions.write', 'admin.users.write', 'admin.audit.read'. Grant all permissions to admin role.
```

> Pro tip: After creating the schema, immediately create an admin user by inserting a row into user_roles linking your own user_id to the admin role. Without this, the permission matrix will render but no one will be able to edit it.

**Expected result:** All five tables are created. The has_permission() function exists. The admin role has all permissions. Your user has the admin role. TypeScript types are generated.

### 2. Build the permission matrix UI

Create the admin page where permissions are assigned to roles. The matrix has roles as rows and permissions as columns, grouped by resource. Each cell is a Checkbox that writes to the database on change.

```
Build a permission matrix admin page at src/pages/PermissionMatrix.tsx.

Data fetching:
- Fetch all roles from the roles table
- Fetch all permissions from the permissions table, ordered by resource then name
- Fetch all role_permissions rows (just role_id and permission_id columns)

Matrix layout:
- Group permissions by their resource field into column groups (e.g. Reports, Users, Admin)
- Render a sticky header row with grouped column labels
- Each subsequent row is one role
- Each cell is a shadcn/ui Checkbox
- The Checkbox is checked if a role_permissions row exists for that (role_id, permission_id) pair
- Checking the Checkbox inserts a new role_permissions row via supabase.from('role_permissions').insert()
- Unchecking deletes the row via supabase.from('role_permissions').delete().match({role_id, permission_id})
- System roles (is_system=true) show Checkboxes as disabled — cannot be modified

UI details:
- Show a loading Skeleton while data fetches
- Show a toast notification on successful save: 'Permission updated'
- Show a toast on error: 'Could not update permission. Check your access.'
- Add a legend at the top explaining the dot notation format
- Permission names display as Badges with the resource part in a muted color and the action part bold

Access gate: if the current user does not have 'admin.permissions.write' permission (check by calling supabase.rpc('has_permission', {p_user_id: user.id, p_permission: 'admin.permissions.write'})), show an Alert with message 'You do not have permission to manage roles.'
```

> Pro tip: Debounce rapid Checkbox toggles with a 300ms delay to avoid race conditions when a user quickly toggles multiple permissions. Use an optimistic UI update (update local state immediately) then revert if the Supabase call fails.

**Expected result:** The permission matrix renders as a grid. Clicking a Checkbox immediately updates local state, then persists to the database. Users without admin.permissions.write see an access denied message.

### 3. Build the user management page with role assignment

Create the page where admins view all users and assign or remove roles. User listing requires the service role (auth.users is not accessible via the anon key), so build an Edge Function for it.

```
Create a Supabase Edge Function at supabase/functions/list-users/index.ts that:
1. Verifies the calling user has 'admin.users.write' permission by calling has_permission() via the service role client
2. If not authorized, return 403
3. Fetches all users from auth.users using the admin API: supabaseAdmin.auth.admin.listUsers()
4. For each user, fetches their current roles from user_roles joined to roles
5. Returns an array of { id, email, created_at, roles: [{id, name}][] }

Then build src/pages/UserManagement.tsx:
- DataTable with columns: Email, Roles (rendered as Badges), Joined (relative date), Actions
- 'Assign Role' Button in the Actions column opens a Dialog
- Dialog shows all available roles as a Checkbox group
- Currently assigned roles are pre-checked
- Saving the Dialog diff: insert new role assignments, delete removed ones
- Each assignment/removal writes to permission_audit_log
- Show a search Input above the table filtering by email
- Access gate: only users with 'admin.users.write' can see this page
```

> Pro tip: When deleting a user's role, don't just delete the user_roles row — also check if any resources they owned should be reassigned. Add a confirmation Dialog that warns: 'Removing the admin role will revoke all admin access immediately.'

**Expected result:** The user management page lists all users with their roles. Clicking Assign Role opens a Dialog. Saving the Dialog updates the user's roles in real time.

### 4. Apply has_permission() to your existing RLS policies

Now that the RBAC system exists, update your other tables to use has_permission() instead of hardcoded role checks. This is the step that makes the matrix changes actually enforce access.

```
// Example RLS policies using has_permission()
// Run these in Supabase Dashboard → SQL Editor

-- Reports table: only users with reports.view can read
CREATE POLICY "reports_select_permission"
ON reports
FOR SELECT
TO authenticated
USING (has_permission(auth.uid(), 'reports.view'));

-- Reports table: only users with reports.write can insert
CREATE POLICY "reports_insert_permission"
ON reports
FOR INSERT
TO authenticated
WITH CHECK (has_permission(auth.uid(), 'reports.write'));

-- Sensitive data table: viewer and above can read
CREATE POLICY "data_select_permission"
ON sensitive_data
FOR SELECT
TO authenticated
USING (has_permission(auth.uid(), 'data.view'));

-- Delete restricted to users with explicit delete permission
CREATE POLICY "data_delete_permission"
ON sensitive_data
FOR DELETE
TO authenticated
USING (has_permission(auth.uid(), 'data.delete'));

-- Tip: has_permission() is STABLE so Postgres can cache results within a transaction.
-- For row-owner bypass: USING (owner_id = auth.uid() OR has_permission(auth.uid(), 'admin.all'))
```

> Pro tip: Use OR owner_id = auth.uid() in your RLS policies to allow users to always access their own data regardless of role. This prevents a permissions misconfiguration from locking users out of their own content.

**Expected result:** Tables with permission-based RLS policies now enforce access based on the role_permissions table. Revoking a permission in the matrix immediately affects database access.

### 5. Add a permission guard hook for the frontend

Create a React hook that checks permissions client-side to show and hide UI elements. This is for UX only — real enforcement stays in the database RLS. Never rely solely on frontend permission checks.

```
// src/hooks/usePermission.ts
import { useState, useEffect } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { useAuth } from '@/contexts/AuthContext'

const permissionCache = new Map<string, boolean>()

export function usePermission(permission: string): {
  hasPermission: boolean
  loading: boolean
} {
  const { user } = useAuth()
  const cacheKey = `${user?.id}:${permission}`

  const [hasPermission, setHasPermission] = useState(() => {
    return permissionCache.get(cacheKey) ?? false
  })
  const [loading, setLoading] = useState(!permissionCache.has(cacheKey))

  useEffect(() => {
    if (!user?.id) {
      setHasPermission(false)
      setLoading(false)
      return
    }

    if (permissionCache.has(cacheKey)) {
      setHasPermission(permissionCache.get(cacheKey)!)
      setLoading(false)
      return
    }

    supabase
      .rpc('has_permission', { p_user_id: user.id, p_permission: permission })
      .then(({ data }) => {
        const result = data as boolean
        permissionCache.set(cacheKey, result)
        setHasPermission(result)
        setLoading(false)
      })
  }, [user?.id, permission, cacheKey])

  return { hasPermission, loading }
}

// Usage in components:
// const { hasPermission, loading } = usePermission('reports.export')
// {hasPermission && <Button>Export</Button>}
```

> Pro tip: Invalidate the permissionCache when a user's roles change. Add a Supabase Realtime subscription on user_roles filtered by the current user's ID and call permissionCache.clear() on any change.

**Expected result:** Components can call usePermission('reports.view') to conditionally render UI elements. The cache prevents redundant RPC calls during a session.

## Complete code example

File: `src/hooks/usePermission.ts`

```typescript
import { useState, useEffect, useCallback } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { useAuth } from '@/contexts/AuthContext'

const permissionCache = new Map<string, boolean>()

export function usePermission(permission: string): {
  hasPermission: boolean
  loading: boolean
  refetch: () => void
} {
  const { user } = useAuth()
  const cacheKey = `${user?.id}:${permission}`

  const [hasPermission, setHasPermission] = useState<boolean>(() => {
    return permissionCache.get(cacheKey) ?? false
  })
  const [loading, setLoading] = useState<boolean>(!permissionCache.has(cacheKey))

  const check = useCallback(async () => {
    if (!user?.id) {
      setHasPermission(false)
      setLoading(false)
      return
    }
    setLoading(true)
    const { data, error } = await supabase.rpc('has_permission', {
      p_user_id: user.id,
      p_permission: permission,
    })
    if (!error) {
      const result = data as boolean
      permissionCache.set(cacheKey, result)
      setHasPermission(result)
    }
    setLoading(false)
  }, [user?.id, permission, cacheKey])

  useEffect(() => {
    if (permissionCache.has(cacheKey)) {
      setHasPermission(permissionCache.get(cacheKey)!)
      setLoading(false)
      return
    }
    check()
  }, [cacheKey, check])

  return { hasPermission, loading, refetch: check }
}

export function clearPermissionCache(userId?: string) {
  if (userId) {
    for (const key of permissionCache.keys()) {
      if (key.startsWith(userId)) permissionCache.delete(key)
    }
  } else {
    permissionCache.clear()
  }
}
```

## Common mistakes

- **Relying only on frontend permission checks to hide buttons or pages** — Frontend permission checks are UX enhancements, not security. A user can call your API directly without going through the UI, bypassing any React-level guards. Fix: Always enforce permissions at the database layer via RLS policies that call has_permission(). The frontend checks are for showing/hiding UI elements only.
- **Creating the has_permission() function without SECURITY DEFINER** — Without SECURITY DEFINER, the function runs with the calling user's permissions. Since user_roles has RLS, a user might not be able to query it, causing has_permission() to always return false. Fix: Declare the function with SECURITY DEFINER so it always runs as the function owner (postgres), who can bypass RLS. This is safe because the function only reads permission data — it doesn't expose other users' data.
- **Not adding an index on user_roles(user_id) and role_permissions(role_id)** — has_permission() is called on every RLS-protected query. Without indexes, each call does a full table scan on user_roles and role_permissions, causing severe performance degradation as those tables grow. Fix: Add: CREATE INDEX ON user_roles(user_id); CREATE INDEX ON role_permissions(role_id); CREATE INDEX ON permissions(name);
- **Allowing users to modify their own roles via the user management page** — A user with admin access could escalate their own permissions or prevent themselves from being demoted. Fix: In the user management Edge Function, check that the actor is not modifying their own roles. Return 403 with 'Cannot modify your own role assignments' if actor_id === target_user_id.
- **Forgetting to seed a default admin role assignment before deploying** — If no user has the admin role, no one can access the permission matrix to assign roles — creating a locked-out state. Fix: After schema creation, immediately run: INSERT INTO user_roles (user_id, role_id) SELECT 'YOUR_USER_ID', id FROM roles WHERE name = 'admin'; before writing any application code.

## Best practices

- Use dot-notation permission names (resource.action) to make permissions self-documenting and group them naturally in the matrix UI
- Mark system roles (admin, viewer) as is_system=true and prevent their deletion — only custom roles should be deletable
- Log every permission change to permission_audit_log with actor, target, and timestamp — this is often required for compliance
- Cache has_permission() results in the frontend for the duration of a session to avoid repeated RPC calls on every render
- Never expose auth.users directly to the frontend — always fetch user lists through a service-role Edge Function that you control
- Test RLS policies by switching to a restricted role in Supabase Dashboard → Authentication → Policies tester before deploying
- Add a 'superadmin' concept that bypasses has_permission() using a separate is_superadmin flag in user profiles — for recovery scenarios only
- Document every permission name in a seed script with descriptions so future developers know what each permission controls

## Frequently asked questions

### What's the difference between RBAC and just checking if a user is an admin?

Hardcoded admin checks create an all-or-nothing system. RBAC lets you create granular roles like 'billing-viewer' (can see invoices, nothing else) or 'content-editor' (can write posts but not publish them). Adding a new role never requires code changes — you just insert rows in the database.

### How does has_permission() perform under load?

With proper indexes on user_roles(user_id), role_permissions(role_id), and permissions(name), has_permission() runs in under 1ms for typical team sizes. The STABLE marking allows PostgreSQL to cache the result within a transaction. For high-traffic apps, consider materializing user permissions into a user_permission_cache table and refreshing it on role changes.

### Can a user have multiple roles?

Yes. The user_roles table is a many-to-many join — one user can have many roles. has_permission() returns true if ANY of the user's roles has the requested permission. This means permissions accumulate across roles, which is the standard RBAC behavior.

### What happens if I delete a permission that's currently in use?

The role_permissions rows referencing that permission are deleted by ON DELETE CASCADE. Any RLS policy that calls has_permission() with the deleted permission name will return false for all users, effectively locking down that resource. Always test permission deletions in a staging environment first.

### How do I handle row-level permissions where users should only see their own data?

Combine has_permission() with an ownership check in your RLS policy: USING (owner_id = auth.uid() OR has_permission(auth.uid(), 'data.view_all')). Regular users see only their own rows. Users with the data.view_all permission see everything. This pattern handles 90% of row-level access requirements.

### Can I use this RBAC system in Edge Functions?

Yes. In your Edge Functions, create a Supabase client with the service role key and call supabase.rpc('has_permission', { p_user_id: userId, p_permission: 'action.name' }). The RPC call hits the same SQL function. This lets you enforce permissions in Edge Function logic, not just in RLS policies.

### How do I test that my RLS policies are working correctly?

In Supabase Dashboard → Authentication → Policies, use the RLS tester. Set auth.uid() to different user IDs and run SELECT queries against your tables to verify only authorized rows are returned. Also write automated tests by creating test users with specific roles and asserting query results from their perspective.

### Can I get help designing the right permission structure for my app?

The RapidDev team helps founders design permission taxonomies that match their product's access model — avoiding both over-permissioning (everyone is admin) and under-permissioning (users constantly hitting access errors). Reach out at rapidevelopers.com.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/user-permission-management
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/user-permission-management
