Skip to main content

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.

Version: 1.0.0
Last Updated: 2025-01-25
Status: Active
Quick Reference: For quick lookup, see permissions-quick-reference.md
Specification: See PF-30 Specification

Table of Contents

  1. System Overview
  2. Design Decisions
  3. Migration History (V1 → V2)
  4. Review & Recommendations
  5. Implementation Guidelines
  6. References

1. System Overview

The Encore Health OS Platform uses a granular permissions system (V2) that replaced the legacy role-based system (V1). V2 provides flexible, module-specific permissions with support for custom roles, site-scoped assignments, and time-limited access.

Current Status

V2 (Active - Mandatory): Granular permissions system
  • ✅ Flexible: {module}.{entity}.{action} format (e.g., hr.employees.view)
  • ✅ Custom roles per organization
  • ✅ Site-scoped and time-limited assignments
  • ✅ 165+ granular permissions
  • Mandatory: All organizations use V2 exclusively (no feature flag)
V1 (Deprecated): Legacy pf_user_roles table
  • Deprecated: No longer in use
  • ❌ Table marked read-only (retained for audit/rollback purposes)
  • ❌ All code migrated to V2

Permission Key Format

{module}.{entity}.{action}
Examples:
  • hr.employees.view - View HR employees
  • fa.bills.approve - Approve FA bills
  • rh.residents.create - Create RH residents
  • system.platform.admin - Platform admin access

Core Components

ComponentPurpose
PermissionGateConditional rendering based on permission
RequirePermissionPage-level protection
useHasPermission()Hook for permission logic

Database Tables

TablePurpose
pf_user_role_assignmentsUser-to-role assignments with org/site scope
pf_rolesRole definitions (custom + system)
pf_role_permissionsPermission-to-role mappings
pf_permissionsPermission definitions
pf_user_rolesDEPRECATED - V1 table, read-only

2. Design Decisions

This section documents key architectural decisions for the PF-30 Permissions System V2.

Decision 1: Site-Scope Interaction with Custom Roles

Chosen Approach: Custom roles are always org-scoped, site-scope is applied at assignment. Rationale:
  1. Simplicity: Custom roles define WHAT permissions a user has. Role assignments define WHERE those permissions apply.
  2. Flexibility: Same custom role can be used across different sites without duplication.
  3. Maintainability: Changing a role’s permissions updates all assignments automatically.
  4. Precedent: Mirrors how system roles already work with site assignments.
Implementation:
  • Custom roles table (pf_custom_roles) does NOT have site_id column
  • Role assignments table (pf_user_role_assignments) has optional site_ids UUID[] column
  • Permission checks must consider site scope from assignment, not from role definition

Decision 2: V2 is Mandatory (No Feature Flag)

Chosen Approach: V2 permissions system is mandatory for all organizations. Rationale:
  1. Migration complete: All organizations have been migrated to V2
  2. Simplified codebase: No conditional logic based on feature flags
  3. Consistent behavior: All users experience the same permission system
  4. Reduced maintenance: Single code path to maintain
Note: The permissions_v2_enabled column still exists in the database for historical purposes but is no longer checked in code. All organizations are assumed to have V2 enabled. Note: Feature flag has been removed post-migration. V2 is now mandatory.

Decision 3: Base Role Inheritance Constraint

Decision: Add CHECK constraint to prevent inheriting from platform_admin or org_admin. Rationale:
  1. Security: Prevents accidental privilege escalation
  2. Clear hierarchy: Custom roles should not exceed org_admin permissions
  3. Explicit: Database-level enforcement, not just application logic
Allowed Base Roles:
  • finance_admin
  • site_admin
  • finance_staff
  • staff
  • readonly
Disallowed Base Roles:
  • platform_admin (system-level only)
  • org_admin (already has full org access)

Decision 4: Foreign Key References

Decision: Use pf_profiles(id) for all user references. Rationale:
  1. Consistency: All other PF tables reference pf_profiles
  2. Query simplicity: Can join directly without crossing schema boundaries
  3. RLS compatibility: pf_profiles has RLS policies, auth.users doesn’t
  4. Extensibility: Additional user metadata available without joining

Decision 5: Permission Caching Strategy

Decision: Use React Query with 5-minute staleTime, database indexes for query optimization. Rationale:
  1. Consistency with platform: Constitution §6.5 specifies 5-min staleTime, 10-min gcTime
  2. Balance: Fresh enough for security, cached enough for performance
  3. Invalidation: Role/permission changes invalidate queries immediately

3. Migration History (V1 → V2)

Migration Overview

Migration Period: December 22-23, 2025
Status: ✅ COMPLETE
Total Files Modified: ~60

Migration Phases

Phase 1: Foundation ✅

  • Enabled V2 for all organizations
  • Updated navigation system (removed minRole)
  • Updated core hooks and utilities
  • Updated module registry

Phase 2: Components ✅

  • Migrated dashboard components
  • Migrated navigation components
  • Migrated core module components
  • Migrated data manager components

Phase 3: RLS & Database ✅

  • Updated all V2 helper functions to use pf_user_role_assignments
  • Updated component user lookups
  • Updated test infrastructure with dual V1/V2 seeding

Phase 4: Testing & Cleanup ✅

  • Removed deprecated V1 code (useUserRole.ts, minRole, hasMinimumRole(), getHighestRole())
  • Updated documentation (AI guides, module docs)
  • Marked pf_user_roles as read-only

Key Changes

Code Removed:
  • useUserRole.ts - Deleted - hook that queried V1 table
  • minRole property - Removed from all navigation items and TypeScript types
  • ROLE_HIERARCHY - Removed from module-registry.ts
  • getHighestRole() - Removed from module-registry.ts
  • hasMinimumRole() - Removed from module-registry.ts
Database Changes:
  • Helper functions query pf_user_role_assignments first
  • pf_user_roles has deprecation comment
  • pf_user_roles has read-only trigger
  • V2 enabled for all organizations

Migration Patterns

Pattern 1: Navigation Items

// ❌ Before (V1)
{ label: 'Employees', path: '/hr/employees', minRole: 'staff' }

// ✅ After (V2)
{ label: 'Employees', path: '/hr/employees', permission: 'hr.employees.view' }

Pattern 2: Component Permission Checks

// ❌ Before (V1)
import { useUserRole } from '@/platform/modules/useUserRole';
const { role } = useUserRole();
if (role === 'org_admin') { ... }

// ✅ After (V2)
import { useHasPermission } from '@/platform/permissions';
const canAdmin = useHasPermission('system.platform.admin');
if (canAdmin) { ... }

Pattern 3: RLS Policies

-- ❌ Before (V1)
CREATE POLICY "org_admins_can_edit" ON some_table
FOR UPDATE
USING (has_role(auth.uid(), 'org_admin'));

-- ✅ After (V2)
CREATE POLICY "org_admins_can_edit" ON some_table
FOR UPDATE
USING (pf_has_permission(auth.uid(), organization_id, 'module.entity.edit'));

Role to Permission Mapping

V1 RoleV2 Permission Pattern
platform_adminsystem.platform.admin
org_adminsystem.platform.admin (or module-specific *.admin)
site_admin*.view + *.create + *.edit (no admin)
staff*.view + *.create
readonly*.view only
finance_adminfa.admin + all FA permissions
finance_stafffa.*.view + fa.*.create (no approve/admin)

4. Review & Recommendations

System Architecture Review

The permissions system overhaul is architecturally sound and aligns with constitution requirements, integrates with existing systems (PF-26 object permissions), and ensures proper security, testing, and documentation. Key Findings:
  1. ✅ Multi-tenant design correctly includes organization_id in all tables
  2. ✅ Uses pf_ prefix for Platform Foundation tables
  3. ✅ Includes audit columns (created_at, updated_at, created_by, updated_by)
  4. ✅ Permissions system is Platform Foundation (PF), not core-specific

Database Schema Compliance

Required Schema Elements:
  • custom_fields JSONB on business entity tables
  • created_by and updated_by audit columns
  • site_id where applicable (for site-scoped permissions)
  • ✅ Descriptive comments on columns
  • ✅ RLS enabled on all tables with SECURITY DEFINER functions

Integration with Existing Systems

PF-26 Object Permissions Integration: The system integrates with existing pf_object_permissions and pf_field_permissions (PF-26):
  • Module Permissions (New): Control access to features/modules (e.g., “Can access HR module?”)
  • Object Permissions (PF-26): Control access to specific entities (e.g., “Can view Employee records?”)
  • Field Permissions (PF-26): Control field-level access (e.g., “Can edit salary field?”)
Permission Check Hierarchy:
  1. Module permission (e.g., hr.employees.view)
  2. Object permission (e.g., hr_employees.can_view for role)
  3. Field permission (e.g., hr_employees.salary.permission_level)

RLS Policy Requirements

CRITICAL: All tables MUST have RLS enabled with SECURITY DEFINER functions to avoid recursion. Required RLS Policies:
  • Users can view org custom roles
  • Org admins can manage custom roles
  • All authenticated users can view permissions (system-wide, read-only)
  • Users can view role permissions for their org
  • Org admins can manage role permissions
  • Users can view their own role assignments
  • Org admins can manage role assignments
Anti-Pattern (CRITICAL):
-- ❌ NEVER DO THIS (Causes infinite recursion)
CREATE POLICY "document_org_access" ON pf_documents
FOR SELECT USING (
 organization_id IN (
 SELECT organization_id FROM pf_user_role_assignments 
 WHERE user_id = auth.uid() -- This table also has RLS!
 )
);

-- ✅ USE SECURITY DEFINER FUNCTIONS WITH V2 PERMISSIONS
-- Option 1: Use built-in permission function (recommended)
CREATE POLICY "document_org_access" ON pf_documents
FOR SELECT USING (
  pf_has_permission(auth.uid(), organization_id, 'pf.documents.view')
);

-- Option 2: Custom SECURITY DEFINER function (if needed)
CREATE OR REPLACE FUNCTION has_org_access(org_id uuid, user_id uuid)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
 RETURN EXISTS (
 SELECT 1 FROM pf_user_role_assignments
 WHERE pf_user_role_assignments.organization_id = org_id
 AND pf_user_role_assignments.user_id = user_id
 AND (pf_user_role_assignments.expires_at IS NULL 
      OR pf_user_role_assignments.expires_at > NOW())
 );
END;
$$;

Testing Requirements

Per Constitution §3.3:
Feature TypeUnitRLSIntegrationE2E
Database schema-✅ Required--
Permission checks80%✅ Required✅ Required-
Role management UI80%✅ Required✅ Required-
Critical flows80%✅ Required✅ Required✅ Required
Critical Flows Requiring E2E:
  • User role assignment workflow
  • Custom role creation and permission assignment
  • Permission inheritance from system roles
  • Cross-organization permission isolation
RLS Test Requirements:
  • ✅ No cross-organization data leakage
  • ✅ Users can only view/manage roles in their organizations
  • ✅ Permission checks respect tenant boundaries
  • ✅ System roles have appropriate access

Security Considerations

Critical Security Requirements:
  1. Tenant Isolation: ✅ RLS policies MUST prevent cross-org access
  2. Permission Escalation: ✅ Users cannot grant themselves permissions
  3. Role Hijacking: ✅ Custom roles cannot inherit from higher system roles
  4. Audit Trail: ✅ All role/permission changes logged to pf_audit_logs
Security Testing:
  • ✅ User cannot access another org’s custom roles
  • ✅ User cannot assign themselves higher permissions
  • ✅ Permission checks respect site scope
  • ✅ Expired role assignments are not honored

5. Implementation Guidelines

Using Permissions in Code

ALWAYS use permission service, not direct queries:
// ✅ CORRECT
import { useHasPermission, PermissionGate } from '@/platform/permissions';

const canViewEmployees = useHasPermission('hr.employees.view');

<PermissionGate permission="hr.employees.edit">
  <EditButton />
</PermissionGate>

// ❌ WRONG - Direct database queries
const { data } = await supabase
  .from('pf_role_permissions')
  .select('*')
  .eq('permission_key', 'hr.employees.view');

Creating Custom Roles

Pattern:
  1. Create role in pf_custom_roles
  2. Assign permissions via pf_role_permissions
  3. Assign role to users via pf_user_role_assignments
{
 label: 'Employees',
 path: '/hr/employees',
 permission: 'hr.employees.view', // Permission-based access control
}

Anti-Patterns

  • ❌ Hardcode role checks (user.role === 'admin')
  • ❌ Create custom roles inheriting from org_admin or platform_admin
  • ❌ Query permission tables directly in RLS policies
  • ❌ Use V1 patterns (minRole, useUserRole(), hasMinimumRole())

6. References

Documentation

Implementation

Historical (Archived)

  • Archived permissions documentation available in docs/archive/permissions/ for historical reference

Last Updated: 2025-01-25
Status: Active Documentation