The MLM CMMS implements a comprehensive Role-Based Access Control (RBAC) system with permissions enforced at both the frontend (UI guards) and backend (Row-Level Security policies) layers.
CREATE TABLE public.roles ( id serial PRIMARY KEY, name text NOT NULL UNIQUE, description varchar NULL, is_system boolean NOT NULL DEFAULT false, created_at timestamptz DEFAULT now());
Key Fields:
is_system: System roles cannot be deleted (e.g., “Admin”, “Super Admin”)
Source: sql/modules/core_cmms/03_tables.sql:18
Purpose: Registry of all available permissions
CREATE TABLE public.permissions ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), resource text NOT NULL, action permission_action NOT NULL, code text NOT NULL UNIQUE, label text NOT NULL, description text, is_active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now());
Key Fields:
resource: The entity being accessed (e.g., 'work_orders', 'users', 'inventory')
action: The operation (e.g., 'create', 'read', 'update', 'delete', 'full_access')
code: Unique identifier as resource:action (e.g., 'work_orders:read')
label: Human-readable name for UI
Source: sql/modules/core_cmms/03_tables.sql:45
Purpose: Many-to-many mapping of roles to permissions
CREATE TABLE public.role_permissions ( role_id int NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id uuid NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id));
Cascade Behavior: Deleting a role or permission automatically removes the mapping.Source: sql/modules/core_cmms/03_tables.sql:56
Purpose: Many-to-many mapping of users to roles
CREATE TABLE public.user_roles ( user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, role_id int NOT NULL REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY (user_id, role_id));
Note: Links to auth.users (Supabase Auth) not public.users.Source: sql/modules/core_cmms/03_tables.sql:62
The me_has_permission(code) function is the core authorization primitive:
CREATE OR REPLACE FUNCTION public.me_has_permission(perm_code text)RETURNS booleanLANGUAGE sqlSECURITY DEFINERAS $$ SELECT EXISTS ( SELECT 1 FROM public.user_roles ur JOIN public.role_permissions rp ON rp.role_id = ur.role_id JOIN public.permissions p ON p.id = rp.permission_id WHERE ur.user_id = auth.uid() AND p.code = perm_code AND p.is_active = true );$$;
How it works:
Gets current user ID via auth.uid() (Supabase helper)
Joins through user_roles → role_permissions → permissions
Checks if any of the user’s roles have the specified permission code
All tables have RLS enabled and policies that use me_has_permission():
Tickets (Select)
Tickets (Insert)
Tickets (Update)
Tickets (Delete)
Users
Assignees
Work Orders (Read)
CREATE POLICY tickets_select_work_orders ON public.tickets FOR SELECT USING ( is_accepted = true AND ( me_has_permission('work_orders:read') OR me_has_permission('work_orders:full_access') ) );
Work Requests (Read)
CREATE POLICY tickets_select_requests ON public.tickets FOR SELECT USING ( is_accepted = false AND ( me_has_permission('work_requests:read') OR me_has_permission('work_requests:full_access') ) );
Separate policies for work requests vs work orders based on is_accepted flag.
CREATE POLICY tickets_insert_rbac ON public.tickets FOR INSERT WITH CHECK ( me_has_permission('work_orders:create') OR me_has_permission('work_orders:full_access') );
Note: INSERT policies use WITH CHECK instead of USING.
Work Orders (Update)
CREATE POLICY tickets_update_work_orders ON public.tickets FOR UPDATE USING ( is_accepted = true AND ( me_has_permission('work_orders:full_access') OR me_has_permission('work_orders:cancel') ) );
Work Requests (Update)
CREATE POLICY tickets_update_requests ON public.tickets FOR UPDATE USING ( is_accepted = false AND ( me_has_permission('work_requests:full_access') OR me_has_permission('work_requests:cancel') ) );
Work Orders (Delete)
CREATE POLICY tickets_delete_work_orders ON public.tickets FOR DELETE USING ( is_accepted = true AND me_has_permission('work_orders:delete') );
Work Requests (Delete)
CREATE POLICY tickets_delete_requests ON public.tickets FOR DELETE USING ( is_accepted = false AND me_has_permission('work_requests:delete') );
-- View usersCREATE POLICY users_select_rbac ON public.users FOR SELECT USING ( me_has_permission('users:read') OR me_has_permission('users:full_access') );-- Create usersCREATE POLICY users_insert_rbac ON public.users FOR INSERT WITH CHECK ( me_has_permission('users:create') OR me_has_permission('users:full_access') );-- Update usersCREATE POLICY users_update_rbac ON public.users FOR UPDATE USING ( me_has_permission('users:update') OR me_has_permission('users:full_access') );-- Delete usersCREATE POLICY users_delete_rbac ON public.users FOR DELETE USING (me_has_permission('users:delete'));
-- View assigneesCREATE POLICY assignees_select_rbac ON public.assignees FOR SELECT USING ( me_has_permission('assignees:read') OR me_has_permission('assignees:full_access') );-- Create assigneesCREATE POLICY assignees_insert_rbac ON public.assignees FOR INSERT WITH CHECK (me_has_permission('assignees:full_access'));-- Update assigneesCREATE POLICY assignees_update_rbac ON public.assignees FOR UPDATE USING (me_has_permission('assignees:full_access'));-- Delete assigneesCREATE POLICY assignees_delete_rbac ON public.assignees FOR DELETE USING (me_has_permission('assignees:delete'));
See sql/modules/core_cmms/09_policies.sql for all policy definitions.
Grants are configured in sql/modules/core_cmms/15_grants_auth.sql:
-- Schema accessGRANT USAGE ON SCHEMA public TO anon, authenticated;GRANT USAGE ON SCHEMA storage TO anon, authenticated;-- Function executionGRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO anon, authenticated;-- Specific revocations for securityREVOKE ALL ON FUNCTION public.me_has_permission(text) FROM PUBLIC;GRANT EXECUTE ON FUNCTION public.me_has_permission(text) TO authenticated;
The me_has_permission() function uses SECURITY DEFINER which executes with the permissions of the function owner (usually the database owner). This allows it to bypass RLS when querying the permission tables.
The sql/modules/core_cmms/10_seed_admin_permissions.sql script inserts the initial permission registry. This should be updated whenever new permissions are added.
Best Practice: Keep permissionRegistry.ts and 10_seed_admin_permissions.sql in sync. Use version control to track changes.
Many resources have a full_access permission that combines multiple actions:
// Frontend route allows any of these permissionsallowPerms: [ 'users:read', 'users:full_access',]// Backend policy checks for full_access OR specific permissionUSING ( me_has_permission('users:read') OR me_has_permission('users:full_access'))
INSERT INTO public.roles (name, description, is_system)VALUES ('Warehouse Manager', 'Manages inventory and parts', false);
Assign permissions:
INSERT INTO public.role_permissions (role_id, permission_id)SELECT 1, id FROM public.permissionsWHERE code IN ( 'inventory:read', 'inventory:create', 'inventory:approve', 'inventory:full_access');
Assign to users:
INSERT INTO public.user_roles (user_id, role_id)VALUES ('user-uuid-here', 1);