Skip to main content
RapidDev - Software Development Agency

How to Build a User Permission Management with Lovable

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.

What you'll build

  • A roles table and permissions table with a many-to-many role_permissions join
  • A has_permission(user_id, permission_name) SQL function callable from Edge Functions
  • A user_roles table linking users to one or more roles
  • A permission matrix UI where rows are roles and columns are permissions with Checkbox toggles
  • An RLS policy on every sensitive table that calls has_permission() to enforce access
  • A user management page listing users with their assigned roles and a role assignment Dialog
  • An audit_log table recording every permission and role change with who made it
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced15 min read3–4 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
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.

What you're building

RBAC (Role-Based Access Control) separates what users can do from who those users are. Instead of checking 'is this user an admin?' you check 'does this user have the posts.delete permission?' Roles are collections of permissions. Users are assigned roles. This means you can create custom roles (e.g. 'Editor with no delete') without changing any application code.

The has_permission() PostgreSQL function is the enforcement layer. It takes a user_id and a permission name (e.g. 'reports.view'), looks up the user's roles via user_roles, checks if any of those roles has the permission in role_permissions, and returns a boolean. Every RLS policy that needs access control calls this function: USING (has_permission(auth.uid(), 'reports.view')).

The permission matrix UI is an admin tool. Rows represent roles, columns represent permissions (grouped by resource). Each cell is a Checkbox. Checking a cell inserts a row_permissions row; unchecking deletes it. Changes take effect immediately because RLS reads the database on every query. The user management page shows all users from auth.users (fetched via a secure Edge Function) with their current roles, and lets admins assign or remove roles through a Dialog.

Final result

A complete RBAC system with database-enforced permissions, a visual matrix editor, and user role management — ready to drop into any Lovable project.

Tech stack

LovablePermission matrix and user management frontend
SupabaseDatabase, RLS enforcement, SQL functions
Supabase AuthUser identity for role lookups
shadcn/uiDataTable, Checkbox, Dialog, Badge, Tabs
TanStack Table v8Permission matrix grid rendering
ZodRole and permission form validation

Prerequisites

  • 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)

Build steps

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.

prompt.txt
1Create a complete RBAC schema in Supabase with these tables and function:
2
3Tables:
4
51. roles:
6 id uuid primary key default gen_random_uuid()
7 name text not null unique (e.g. 'admin', 'editor', 'viewer')
8 description text
9 is_system boolean default false (system roles cannot be deleted)
10 created_at timestamptz default now()
11
122. permissions:
13 id uuid primary key default gen_random_uuid()
14 name text not null unique (dot-notation: 'resource.action', e.g. 'reports.view', 'users.delete')
15 description text
16 resource text not null (the first part before the dot, for grouping)
17 created_at timestamptz default now()
18
193. role_permissions:
20 role_id uuid references roles(id) on delete cascade
21 permission_id uuid references permissions(id) on delete cascade
22 primary key (role_id, permission_id)
23 granted_by uuid references auth.users(id)
24 granted_at timestamptz default now()
25
264. user_roles:
27 user_id uuid references auth.users(id) on delete cascade
28 role_id uuid references roles(id) on delete cascade
29 primary key (user_id, role_id)
30 assigned_by uuid references auth.users(id)
31 assigned_at timestamptz default now()
32
335. permission_audit_log:
34 id uuid primary key default gen_random_uuid()
35 action text not null (e.g. 'role.permission.granted', 'user.role.assigned')
36 actor_id uuid references auth.users(id)
37 target_role_id uuid references roles(id)
38 target_user_id uuid references auth.users(id)
39 permission_id uuid references permissions(id)
40 metadata jsonb
41 created_at timestamptz default now()
42
43SQL Function:
44CREATE OR REPLACE FUNCTION has_permission(p_user_id uuid, p_permission text)
45RETURNS boolean
46LANGUAGE sql
47STABLE SECURITY DEFINER
48AS $$
49 SELECT EXISTS (
50 SELECT 1
51 FROM user_roles ur
52 JOIN role_permissions rp ON rp.role_id = ur.role_id
53 JOIN permissions p ON p.id = rp.permission_id
54 WHERE ur.user_id = p_user_id
55 AND p.name = p_permission
56 );
57$$;
58
59RLS:
60- roles: authenticated users can SELECT; only service role can INSERT/UPDATE/DELETE
61- permissions: authenticated users can SELECT; only service role can INSERT/UPDATE/DELETE
62- role_permissions: only users with 'admin.permissions.write' permission can INSERT/DELETE; authenticated SELECT
63- user_roles: only users with 'admin.users.write' permission can INSERT/DELETE; authenticated SELECT
64- permission_audit_log: INSERT allowed for authenticated; SELECT only for users with 'admin.audit.read'
65
66Seed 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.

prompt.txt
1Build a permission matrix admin page at src/pages/PermissionMatrix.tsx.
2
3Data fetching:
4- Fetch all roles from the roles table
5- Fetch all permissions from the permissions table, ordered by resource then name
6- Fetch all role_permissions rows (just role_id and permission_id columns)
7
8Matrix layout:
9- Group permissions by their resource field into column groups (e.g. Reports, Users, Admin)
10- Render a sticky header row with grouped column labels
11- Each subsequent row is one role
12- Each cell is a shadcn/ui Checkbox
13- The Checkbox is checked if a role_permissions row exists for that (role_id, permission_id) pair
14- Checking the Checkbox inserts a new role_permissions row via supabase.from('role_permissions').insert()
15- Unchecking deletes the row via supabase.from('role_permissions').delete().match({role_id, permission_id})
16- System roles (is_system=true) show Checkboxes as disabled cannot be modified
17
18UI details:
19- Show a loading Skeleton while data fetches
20- Show a toast notification on successful save: 'Permission updated'
21- Show a toast on error: 'Could not update permission. Check your access.'
22- Add a legend at the top explaining the dot notation format
23- Permission names display as Badges with the resource part in a muted color and the action part bold
24
25Access 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.

prompt.txt
1Create a Supabase Edge Function at supabase/functions/list-users/index.ts that:
21. Verifies the calling user has 'admin.users.write' permission by calling has_permission() via the service role client
32. If not authorized, return 403
43. Fetches all users from auth.users using the admin API: supabaseAdmin.auth.admin.listUsers()
54. For each user, fetches their current roles from user_roles joined to roles
65. Returns an array of { id, email, created_at, roles: [{id, name}][] }
7
8Then build src/pages/UserManagement.tsx:
9- DataTable with columns: Email, Roles (rendered as Badges), Joined (relative date), Actions
10- 'Assign Role' Button in the Actions column opens a Dialog
11- Dialog shows all available roles as a Checkbox group
12- Currently assigned roles are pre-checked
13- Saving the Dialog diff: insert new role assignments, delete removed ones
14- Each assignment/removal writes to permission_audit_log
15- Show a search Input above the table filtering by email
16- 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.

supabase/migrations/add_permission_rls.sql
1// Example RLS policies using has_permission()
2// Run these in Supabase Dashboard → SQL Editor
3
4-- Reports table: only users with reports.view can read
5CREATE POLICY "reports_select_permission"
6ON reports
7FOR SELECT
8TO authenticated
9USING (has_permission(auth.uid(), 'reports.view'));
10
11-- Reports table: only users with reports.write can insert
12CREATE POLICY "reports_insert_permission"
13ON reports
14FOR INSERT
15TO authenticated
16WITH CHECK (has_permission(auth.uid(), 'reports.write'));
17
18-- Sensitive data table: viewer and above can read
19CREATE POLICY "data_select_permission"
20ON sensitive_data
21FOR SELECT
22TO authenticated
23USING (has_permission(auth.uid(), 'data.view'));
24
25-- Delete restricted to users with explicit delete permission
26CREATE POLICY "data_delete_permission"
27ON sensitive_data
28FOR DELETE
29TO authenticated
30USING (has_permission(auth.uid(), 'data.delete'));
31
32-- Tip: has_permission() is STABLE so Postgres can cache results within a transaction.
33-- 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
1// src/hooks/usePermission.ts
2import { useState, useEffect } from 'react'
3import { supabase } from '@/integrations/supabase/client'
4import { useAuth } from '@/contexts/AuthContext'
5
6const permissionCache = new Map<string, boolean>()
7
8export function usePermission(permission: string): {
9 hasPermission: boolean
10 loading: boolean
11} {
12 const { user } = useAuth()
13 const cacheKey = `${user?.id}:${permission}`
14
15 const [hasPermission, setHasPermission] = useState(() => {
16 return permissionCache.get(cacheKey) ?? false
17 })
18 const [loading, setLoading] = useState(!permissionCache.has(cacheKey))
19
20 useEffect(() => {
21 if (!user?.id) {
22 setHasPermission(false)
23 setLoading(false)
24 return
25 }
26
27 if (permissionCache.has(cacheKey)) {
28 setHasPermission(permissionCache.get(cacheKey)!)
29 setLoading(false)
30 return
31 }
32
33 supabase
34 .rpc('has_permission', { p_user_id: user.id, p_permission: permission })
35 .then(({ data }) => {
36 const result = data as boolean
37 permissionCache.set(cacheKey, result)
38 setHasPermission(result)
39 setLoading(false)
40 })
41 }, [user?.id, permission, cacheKey])
42
43 return { hasPermission, loading }
44}
45
46// Usage in components:
47// const { hasPermission, loading } = usePermission('reports.export')
48// {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

src/hooks/usePermission.ts
1import { useState, useEffect, useCallback } from 'react'
2import { supabase } from '@/integrations/supabase/client'
3import { useAuth } from '@/contexts/AuthContext'
4
5const permissionCache = new Map<string, boolean>()
6
7export function usePermission(permission: string): {
8 hasPermission: boolean
9 loading: boolean
10 refetch: () => void
11} {
12 const { user } = useAuth()
13 const cacheKey = `${user?.id}:${permission}`
14
15 const [hasPermission, setHasPermission] = useState<boolean>(() => {
16 return permissionCache.get(cacheKey) ?? false
17 })
18 const [loading, setLoading] = useState<boolean>(!permissionCache.has(cacheKey))
19
20 const check = useCallback(async () => {
21 if (!user?.id) {
22 setHasPermission(false)
23 setLoading(false)
24 return
25 }
26 setLoading(true)
27 const { data, error } = await supabase.rpc('has_permission', {
28 p_user_id: user.id,
29 p_permission: permission,
30 })
31 if (!error) {
32 const result = data as boolean
33 permissionCache.set(cacheKey, result)
34 setHasPermission(result)
35 }
36 setLoading(false)
37 }, [user?.id, permission, cacheKey])
38
39 useEffect(() => {
40 if (permissionCache.has(cacheKey)) {
41 setHasPermission(permissionCache.get(cacheKey)!)
42 setLoading(false)
43 return
44 }
45 check()
46 }, [cacheKey, check])
47
48 return { hasPermission, loading, refetch: check }
49}
50
51export function clearPermissionCache(userId?: string) {
52 if (userId) {
53 for (const key of permissionCache.keys()) {
54 if (key.startsWith(userId)) permissionCache.delete(key)
55 }
56 } else {
57 permissionCache.clear()
58 }
59}

Customization ideas

Time-limited role assignments

Add an expires_at column to user_roles. Modify has_permission() to check expires_at IS NULL OR expires_at > now(). Use a pg_cron job to notify users 24 hours before their temporary access expires.

Permission inheritance

Add a parent_role_id column to roles. Update has_permission() to recursively check parent roles using a WITH RECURSIVE CTE. This lets you create an 'editor' role that inherits all 'viewer' permissions automatically.

IP-restricted permissions

Add an ip_allowlist jsonb column to roles. In the Edge Function permission check, also verify that the request IP matches the allowlist. This adds a network layer to admin role access.

Permission request workflow

Add a permission_requests table where users can request access to a specific permission. Admins see pending requests on the user management page and can approve with one click, which auto-assigns the appropriate role.

Role templates

Add a 'Clone Role' button in the permission matrix that duplicates an existing role's permission set under a new name. Useful for creating custom roles based on existing ones.

Permission usage analytics

Log each has_permission() call result to a permission_usage_log table (anonymized). Build a chart showing which permissions are checked most often to identify over-permissioned or under-used roles.

Common pitfalls

Pitfall: Relying only on frontend permission checks to hide buttons or pages

How to avoid: Always enforce permissions at the database layer via RLS policies that call has_permission(). The frontend checks are for showing/hiding UI elements only.

Pitfall: Creating the has_permission() function without SECURITY DEFINER

How to avoid: 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.

Pitfall: Not adding an index on user_roles(user_id) and role_permissions(role_id)

How to avoid: Add: CREATE INDEX ON user_roles(user_id); CREATE INDEX ON role_permissions(role_id); CREATE INDEX ON permissions(name);

Pitfall: Allowing users to modify their own roles via the user management page

How to avoid: 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.

Pitfall: Forgetting to seed a default admin role assignment before deploying

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I have a RBAC system in Supabase with roles, permissions, and a has_permission(user_id, permission) SQL function. I want to add permission inheritance: an 'editor' role should automatically inherit all permissions from 'viewer'. How do I modify the has_permission() function to use a WITH RECURSIVE CTE to traverse parent roles? Write the updated PostgreSQL function.

Lovable Prompt

Add a permission request workflow to my RBAC system. Create a permission_requests table with fields: user_id, requested_permission (text), reason (text), status ('pending'|'approved'|'denied'), reviewed_by uuid, reviewed_at timestamptz. Add a 'Request Access' button on the access-denied screen that opens a Dialog to submit a request. On the admin user management page, show a Badge count of pending requests and a Requests tab with an approve/deny interface.

Build Prompt

In my Lovable project I have a permission matrix page that calls supabase.from('role_permissions').insert() when an admin checks a Checkbox. But when I uncheck a permission, I need to call .delete().match({role_id, permission_id}). The problem is React re-renders the Checkbox before the delete finishes, causing the Checkbox to flicker. How do I implement optimistic UI updates for this pattern using React state and Supabase?

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.