> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Permissions V2 Migration — Complete Summary

> The Permissions V2 migration replaced the legacy pf_user_roles table with a granular, permission-based system built on pf_user_role_assignments, pf_role_permis…

**Version:** 1.0.0\
**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 legacy `pf_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:

| Phase                            | Period   | Scope                                                                                 |
| -------------------------------- | -------- | ------------------------------------------------------------------------------------- |
| 1. Initial V2 Migration          | Dec 2025 | \~60 files: navigation, components, hooks, V1 code removal                            |
| 2. Hard Deprecation              | Jan 2026 | Feature flag removed, V2 mandatory, Object Permissions UI updated                     |
| 3. RLS Policy Hardening          | Mar 2026 | ALL→CRUD policy split, 12 helper functions rewritten, `expires_at` enforcement        |
| 4. Trigger Consolidation         | Mar 2026 | Rewired all triggers to `update_updated_at_column()`, dropped 22 deprecated functions |
| 5. V1 Table Drop & Final Cleanup | Mar 2026 | Dropped `pf_user_roles`, fixed last V1 references, dropped legacy overloads           |

**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, 2025\
**Files modified:** \~60

### What Changed

| Category               | Count | Details                                                          |
| ---------------------- | ----- | ---------------------------------------------------------------- |
| Navigation files       | 8     | Removed `minRole` property, added `permission`                   |
| Module registry        | 3     | Removed `ROLE_HIERARCHY`, `getHighestRole()`, `hasMinimumRole()` |
| Dashboard components   | 6     | Switched to `useHasPermission()` / `PermissionGate`              |
| Core module components | 10    | Migrated HR, FA, RH, FW, etc.                                    |
| Platform components    | 7     | Auth, settings, admin panels                                     |
| Edge functions         | 1     | Updated to `verifyOrgAccess()` pattern                           |
| Database helpers       | 11    | Updated to query `pf_user_role_assignments` first                |
| RLS test files         | 8     | Dual V1/V2 seeding                                               |
| Documentation          | 6     | AI guides, module docs                                           |

### Code Removed

* `useUserRole.ts` — hook that queried V1 table
* `minRole` property — from all navigation items and TypeScript types
* `ROLE_HIERARCHY` — from module-registry.ts
* `getHighestRole()` — from module-registry.ts
* `hasMinimumRole()` — from module-registry.ts

### V2 Components Introduced

| Component            | Purpose                                       |
| -------------------- | --------------------------------------------- |
| `PermissionGate`     | Conditional rendering based on permission key |
| `RequirePermission`  | Page-level protection wrapper                 |
| `useHasPermission()` | Hook for permission logic in components       |

**Permission key format:** `{module}.{entity}.{action}` (e.g., `hr.employees.edit`, `fa.bills.approve`)

***

## Phase 2: Hard Deprecation (Jan 2026)

**Completed:** January 26, 2026

### What Changed

| Item                                                              | Status |
| ----------------------------------------------------------------- | ------ |
| `permissions_v2_enabled` feature flag removed from all code paths | ✅      |
| `usePermissionsV2Enabled` hook deleted                            | ✅      |
| `isV2Enabled()` removed from PermissionService                    | ✅      |
| V2 mandatory for all organizations                                | ✅      |
| `pf_user_roles` marked read-only (trigger)                        | ✅      |

### Object Permissions UI Updated

The `ObjectPermissionsTab` component was migrated from hardcoded `ORDERED_ROLES` (5 roles) to dynamic `useAvailableRoles()` hook (all 18 system roles):

| File                                                               | Change                                                |
| ------------------------------------------------------------------ | ----------------------------------------------------- |
| `src/platform/data-manager/hooks/useAvailableRoles.ts`             | New hook — fetches all system roles dynamically       |
| `src/platform/data-manager/components/ObjectPermissionsTab.tsx`    | Uses `useAvailableRoles()` instead of `ORDERED_ROLES` |
| `src/platform/data-manager/components/ObjectPermissionMatrix.tsx`  | Uses `roleLabel` from props, removed `ROLE_CONFIG`    |
| `src/platform/data-manager/components/FieldPermissionsSection.tsx` | Uses `useAvailableRoles()` and `getRoleLabel()`       |
| `src/platform/data-manager/types/permissions.ts`                   | `ORDERED_ROLES` and `ROLE_CONFIG` marked deprecated   |

***

## 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 `ALL` policies permitted
* `WITH CHECK (false)` on deny-all mutation policies
* `FORCE ROW LEVEL SECURITY` on all business tables
* `SECURITY DEFINER` functions set `search_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.

| Helper                                   | Signature Preserved | Notes                                |
| ---------------------------------------- | ------------------- | ------------------------------------ |
| `fw_has_org_access(org_id, user_id)`     | ✅                   | Delegates to `pf_has_org_access`     |
| `fa_has_org_access(p_org_id, p_user_id)` | ✅                   | Delegates to `pf_has_org_access`     |
| `fm_has_org_access(org_id, user_id)`     | ✅                   | Delegates to `pf_has_org_access`     |
| `hr_has_org_access(org_id, user_id)`     | ✅                   | Delegates to `pf_has_org_access`     |
| `it_has_org_access(org_id, user_id)`     | ✅                   | Delegates to `pf_has_org_access`     |
| `lo_has_org_access(org_id, user_id)`     | ✅                   | Delegates to `pf_has_org_access`     |
| `gr_has_org_access(org_id, user_id)`     | ✅                   | Reversed arg mapping preserved       |
| `cl_has_org_access`                      | ✅                   | Already delegating (prior migration) |
| `pm_has_org_access`                      | ✅                   | Already delegating (prior migration) |
| `ce_has_org_access`                      | ✅                   | Fixed separately                     |
| `it_dashboard_has_org_access`            | ✅                   | Fixed separately                     |
| `pf_settings_audit_has_org_access`       | ✅                   | Fixed separately                     |

### 3c. Additional Security Fixes

**Migration:** `20260319181618` — Security audit: `expires_at` checks + deprecated references

* `has_rh_admin_role` — rewired to `pf_user_role_assignments` with `expires_at` filter
* `has_rh_admin_role` — `search_path` fixed (unquoted `public`)
* RH table grants added for `authenticated` role

**Migration:** `20260214153000` — `ce_unmatched_calls` service-role-only access\
**Migration:** `20260214154500` — `gr_has_org_access` legacy arg order fix\
**Migration:** `20260214162000` — `fa_is_finance_admin` permission key join fix\
**Migration:** `20260214170000` — `pf_can_admin_wizards` permission function fix\
**Migration:** `20260305220611` — Enable RLS on 6 previously unprotected tables\
**Migration:** `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

| Migration        | Workstream                   | Cores              |
| ---------------- | ---------------------------- | ------------------ |
| `20260319184533` | 1A: Rewire triggers          | PF, FA, FM, CE     |
| `20260319184716` | 1B: Rewire triggers          | HR, IT, FW, GR, CL |
| `20260319184818` | 1C: Rewire triggers          | PM                 |
| `20260319184926` | 2: Drop deprecated functions | All cores          |

### Functions Dropped (22)

Per-core `set_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`

| Object Dropped                                 | Type      |
| ---------------------------------------------- | --------- |
| `pf_user_roles_readonly_trigger`               | Trigger   |
| `pf_prevent_user_roles_writes()`               | Function  |
| RLS policies on `pf_user_roles`                | Policies  |
| `pf_user_roles`                                | Table     |
| Orphaned functions referencing `pf_user_roles` | Functions |

### 5b. Fix Last V1 References

**Migration:** `20260319194753` — Fix 2 functions still referencing dropped table

| Function                                    | Fix                                         |
| ------------------------------------------- | ------------------------------------------- |
| `hr_has_analytics_access(org_id, user_id)`  | Rewired to delegate to `pf_has_org_access`  |
| `pf_get_slow_queries(p_threshold, p_limit)` | Rewired to query `pf_user_role_assignments` |

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

| Timestamp        | Description                                                            |
| ---------------- | ---------------------------------------------------------------------- |
| `20260214153000` | Fix `ce_unmatched_calls` service-role-only access                      |
| `20260214154500` | Fix `gr_has_org_access` legacy argument order                          |
| `20260214162000` | Fix `fa_is_finance_admin` permission key join                          |
| `20260214170000` | Fix `pf_can_admin_wizards` permission function                         |
| `20260305220611` | Enable RLS on 6 previously unprotected tables                          |
| `20260306120000` | Go-live permissions fix                                                |
| `20260318040458` | Split ALL policies to granular CRUD (PF tables)                        |
| `20260318192816` | Seed all missing `platform_admin` permissions                          |
| `20260319041820` | Rewrite 7 `_has_org_access` helpers to delegate to `pf_has_org_access` |
| `20260319125214` | Seed missing permission keys (`rh.schedule-templates.view`, etc.)      |
| `20260319125324` | Security hardening — `fw_get_sla_status` org\_id enforcement           |
| `20260319161137` | Fix `has_rh_admin_role` to query `pf_user_role_assignments`            |
| `20260319162949` | Fix `has_rh_admin_role` search\_path (unquoted `public`)               |
| `20260319164220` | Grant `authenticated` role access to all `rh_*` tables                 |
| `20260319164431` | Fix `rh_residences` INSERT policy                                      |
| `20260319181618` | Security audit: `expires_at` checks + deprecated references            |
| `20260319182644` | Drop `pf_user_roles` table and related objects                         |
| `20260319194753` | Fix `hr_has_analytics_access` and `pf_get_slow_queries`                |
| `20260319194921` | Drop legacy `pf_get_slow_queries(numeric, integer)` overload           |

### Trigger Consolidation

| Timestamp        | Description                                        |
| ---------------- | -------------------------------------------------- |
| `20260319184533` | Workstream 1A: Rewire PF, FA, FM, CE triggers      |
| `20260319184716` | Workstream 1B: Rewire HR, IT, FW, GR, CL triggers  |
| `20260319184818` | Workstream 1C: Rewire PM triggers                  |
| `20260319184926` | Workstream 2: Drop 22 deprecated trigger functions |

***

## Application Code Changes Summary

### Hooks & Services

| Item                              | Action      | Phase |
| --------------------------------- | ----------- | ----- |
| `useUserRole.ts`                  | Deleted     | 1     |
| `usePermissionsV2Enabled.ts`      | Deleted     | 2     |
| `useHasPermission()`              | Active (V2) | 1     |
| `useAvailableRoles()`             | Created     | 2     |
| `PermissionService.isV2Enabled()` | Removed     | 2     |

### Components

| Component                    | Change                                    | Phase |
| ---------------------------- | ----------------------------------------- | ----- |
| `PermissionGate`             | Introduced                                | 1     |
| `RequirePermission`          | Introduced                                | 1     |
| `ObjectPermissionsTab`       | Migrated to dynamic roles                 | 2     |
| `ObjectPermissionMatrix`     | Removed `ROLE_CONFIG` dependency          | 2     |
| `FieldPermissionsSection`    | Migrated to `useAvailableRoles()`         | 2     |
| Navigation items (\~8 files) | `minRole` → `permission`                  | 1     |
| Dashboard components (\~6)   | `hasMinimumRole()` → `useHasPermission()` | 1     |

### Edge Functions

All edge functions use `verifyOrgAccess()` / `verifyOrgRole()` from `supabase/functions/_shared/auth.ts`. No inline `pf_user_role_assignments` queries permitted.

***

## Database Schema (Current State)

### Active Tables

| Table                      | Purpose                                                       |
| -------------------------- | ------------------------------------------------------------- |
| `pf_user_role_assignments` | User-to-role assignments with org/site scope and `expires_at` |
| `pf_roles`                 | Role definitions (system + custom)                            |
| `pf_role_permissions`      | Permission-to-role mappings                                   |
| `pf_module_permissions`    | Permission definitions (`{module}.{entity}.{action}`)         |
| `pf_custom_roles`          | Organization-defined custom roles                             |

### Dropped Tables

| Table           | Dropped In       | Replacement                |
| --------------- | ---------------- | -------------------------- |
| `pf_user_roles` | `20260319182644` | `pf_user_role_assignments` |

### 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()`

***

## Remaining Work (Phase 3 — Q3 2026)

| Item                                                        | Status | Notes                                 |
| ----------------------------------------------------------- | ------ | ------------------------------------- |
| Archive `pf_user_roles` data verification                   | ⏳      | Data preserved in migration history   |
| Remove `minRole` property from TypeScript types             | ⏳      | Still exists in some type definitions |
| Remove deprecated `ORDERED_ROLES` / `ROLE_CONFIG` constants | ⏳      | Sunset date: 2026-03-31               |
| V1 fallback logic in PermissionService                      | ⏳      | Dead code after Phase 2               |
| `permissions_v2_enabled` column on `pf_organizations`       | ⏳      | No longer checked                     |

***

## References

| Document                                  | Path                                                            |
| ----------------------------------------- | --------------------------------------------------------------- |
| Deprecation Plan                          | `docs/architecture/deprecation/pf_user_roles.md`                |
| V2 Migration Completion Report (Archived) | `docs/archive/permissions/permissions-v2-migration-complete.md` |
| Object Permissions V2 Migration           | `docs/migration/OBJECT_PERMISSIONS_V2_MIGRATION.md`             |
| Permissions Quick Reference               | `docs/pf/permissions-quick-reference.md`                        |
| Permission Patterns (Cursor Rule)         | `.cursor/rules/permission-patterns.md`                          |
| PF-30 Spec                                | `specs/pf/specs/PF-30-permissions-system-v2.md`                 |
| Deprecation Notice (Specs)                | `specs/_DEPRECATION_NOTICE.md`                                  |
| RLS & Isolation Standards                 | Memory: `architecture/database/rls-and-isolation-standards`     |
