Created: 2026-03-19
Status: ✅ Complete (Phase 3 cleanup deferred to Q3 2026)
Owner: Platform Foundation (PF-30)
Executive Summary
The Permissions V2 migration replaced the legacypf_user_roles table with a granular, permission-based system built on pf_user_role_assignments, pf_role_permissions, and pf_module_permissions. Work spanned December 2025 through March 2026 across five phases:
Current state: V2 is the sole permission system. All RLS policies use granular CRUD. All
_has_org_access helpers delegate to pf_has_org_access with expires_at validation. The pf_user_roles table no longer exists.
Phase 1: Initial V2 Migration (Dec 2025)
Migration period: December 22–23, 2025Files modified: ~60
What Changed
Code Removed
useUserRole.ts— hook that queried V1 tableminRoleproperty — from all navigation items and TypeScript typesROLE_HIERARCHY— from module-registry.tsgetHighestRole()— from module-registry.tshasMinimumRole()— from module-registry.ts
V2 Components Introduced
Permission key format:
{module}.{entity}.{action} (e.g., hr.employees.edit, fa.bills.approve)
Phase 2: Hard Deprecation (Jan 2026)
Completed: January 26, 2026What Changed
Object Permissions UI Updated
TheObjectPermissionsTab component was migrated from hardcoded ORDERED_ROLES (5 roles) to dynamic useAvailableRoles() hook (all 18 system roles):
Phase 3: RLS Policy Hardening (Mar 2026)
3a. Split ALL Policies to Granular CRUD
Migration:20260318040458 — Split ALL policies to CRUD (PF tables)
Replaced every FOR ALL policy with individual SELECT, INSERT, UPDATE, DELETE policies across all business tables. Every UPDATE and INSERT policy includes a WITH CHECK clause. Tables affected include pf_, hr_, fa_, fw_, fm_, ce_, gr_, it_, lo_, rh_, cl_, pm_ prefixed tables.
Standard enforced:
- No
ALLpolicies permitted WITH CHECK (false)on deny-all mutation policiesFORCE ROW LEVEL SECURITYon all business tablesSECURITY DEFINERfunctions setsearch_path = public(unquoted)
3b. Rewired 12 _has_org_access Helpers
Migration: 20260319041820 — Rewrite 7 core helpers to delegate to pf_has_org_access
All core-specific _has_org_access functions now delegate to pf_has_org_access, which validates expires_at on role assignments. This ensures expired assignments are rejected uniformly across all RLS policies.
3c. Additional Security Fixes
Migration:20260319181618 — Security audit: expires_at checks + deprecated references
has_rh_admin_role— rewired topf_user_role_assignmentswithexpires_atfilterhas_rh_admin_role—search_pathfixed (unquotedpublic)- RH table grants added for
authenticatedrole
20260214153000 — ce_unmatched_calls service-role-only accessMigration:
20260214154500 — gr_has_org_access legacy arg order fixMigration:
20260214162000 — fa_is_finance_admin permission key join fixMigration:
20260214170000 — pf_can_admin_wizards permission function fixMigration:
20260305220611 — Enable RLS on 6 previously unprotected tablesMigration:
20260306120000 — Go-live permissions fix
Phase 4: Trigger Consolidation (Mar 2026)
Migrations:20260319184533 through 20260319184926 (4 files)
All updated_at triggers across every core were rewired to the canonical update_updated_at_column() function, then 22 redundant per-core trigger functions were dropped.
Workstreams
Functions Dropped (22)
Per-coreset_updated_at(), update_*_timestamp(), and similar functions that duplicated the canonical update_updated_at_column().
Phase 5: V1 Table Drop & Final Cleanup (Mar 2026)
5a. Drop pf_user_roles Table
Migration: 20260319182644
5b. Fix Last V1 References
Migration:20260319194753 — Fix 2 functions still referencing dropped table
5c. Drop Legacy Overload
Migration:20260319194921
Dropped pf_get_slow_queries(numeric, integer) — the old overload that still referenced pf_user_roles.
Migration File Index
Core V2 Migrations (Permissions & Security)
Trigger Consolidation
Application Code Changes Summary
Hooks & Services
Components
Edge Functions
All edge functions useverifyOrgAccess() / verifyOrgRole() from supabase/functions/_shared/auth.ts. No inline pf_user_role_assignments queries permitted.
Database Schema (Current State)
Active Tables
Dropped Tables
Helper Functions (Current)
All_has_org_access helpers delegate to pf_has_org_access, which enforces:
- User has an active role assignment for the organization
expires_at IS NULL OR expires_at > now()