Access Control Reference
Access Control Reference
Last Updated: September 20, 2026
Last verified against code: 2026-09-20 (role model refactor)
Overview
Pathfinder authorizes on two axes:
- Base role —
profiles.role. Authority, and therefore tenant scope:admin/staffare the Range Lab network; the four organization roles are partner-org users. - Organization —
profiles.org_id. Required for organization roles. Optional for network roles, and when set it must be the singleorganizations.is_networkrow (Range Lab) — their home org, which keys their resources-module roles and the records they create.
The pairing is bound by the database constraint chk_profiles_role_org_scope
(migration 20260920120002) and the guard_profile_authority_columns
trigger (20260920120003), and interpreted only through
lib/access-control.ts. Anything that is not
base authority lives elsewhere: resources tiers in user_module_roles, Corps
personas in volunteer_profiles.corps_role, demo tenancy in
organizations.is_demo. Full rationale: ROLE_MODEL_REFACTOR.md.
Roles
| Role | Tenant | UI label | What it may do |
|---|---|---|---|
admin | Network | Range Lab Admin | Everything. Manages every organization and user. |
staff | Network | Range Lab Staff | Reads across organizations; writes the shared catalogs (deals, income, education, grants, tags, form templates). No user administration. |
org_admin | Organization | Organization Admin | Organization settings; creates/edits supervisor, caseworker, viewer users in their own org. |
supervisor | Organization | Supervisor | Everything a caseworker can, plus household archive/reopen and inventory configuration (categories, items, locations, adjustments). |
caseworker | Organization | Caseworker | Clients, intake, referrals, tasks, inventory movements, resource providers. |
viewer | Organization | Viewer | Read-only. |
volunteer | Portal | Volunteer (Corps) | Corps portal only. No CRM access. Created by Corps signup. |
Retired: demo (→ organizations.is_demo + org_admin), landlord
(Pathfinder's old portal; rows deactivated). RST shares the Supabase project
but does not read profiles — its identity lives in portal.landlord_members
and public.rst_staff.
Central helpers
Use these instead of role arrays. All take a profile-shaped object
({ role, org_id, is_active? }).
| Helper | True for |
|---|---|
getUserScope(p) | { tenantScope, orgId, baseRole }, or null for non-CRM / invalid rows |
canAccessCrm(p) | any active CRM base role — middleware and login gate |
isNetworkScope(p) / isCrossOrgReader(p) | admin, staff — skip the org filter |
canManageNetworkCatalog(p) | admin, staff |
isTenantAdmin(role) | admin, org_admin |
canManageOrgUsers(p) | admin, org_admin |
canManageOrgRecords(p) | admin, org_admin, supervisor |
canWriteOrgData(p) | everyone except viewer |
canAccessOrgRecord(p, recordOrgId) | network scope, or same organization |
assignableRolesFor(actor) | roles the actor may assign |
isValidRoleOrgPair(role, orgId) | mirrors the DB constraint |
Org isolation in queries must key on isCrossOrgReader(profile), never on a
role-name list:
if (!isCrossOrgReader(profile)) query = query.eq('org_id', profile.org_id)
Route Access Matrix
| Route | Gate |
|---|---|
/crm, /clients, /resources, /referrals, /jobs, /events, /resource-navigator, /chatbot | canAccessCrm (middleware) + module check for organization users |
/intake/* (except /intake/self-service) | canAccessCrm (middleware) + canWriteOrgData (page) |
/admin/* | admin; org_admin for /admin/settings and /admin/users only |
/intake/self-service | Public |
/corps/* | Corps login; corps_role |
Protection Layers
- Middleware (
middleware.ts) — session refresh;/admin/*gate; CRM list gate viacanAccessCrm; module gate for organization users (isNetworkScopebypasses). - Page / API —
lib/access-control.tshelpers. - Database —
- RLS keyed on
current_user_role()andcurrent_user_org_id().IN ('admin','staff')means network because the trigger guaranteesstaffcan never point at a partner org and org admins cannot mint it. chk_profiles_role_org_scope— role/org shape.guard_profile_authority_columnstrigger (migration20260920120003) — network roles may only be homed at theis_networkorg; only admins, org admins (own org, manageable roles) and the service role may writeprofiles.role,org_id,is_active.guard_organization_is_networktrigger —is_networkis admin-only.
- RLS keyed on
User management
POST /api/admin/create-uservalidates(target_role, target_org_id)withcanActorAssignRole()before creating the auth user.lib/admin-user-permissions.ts—canActorManageTarget()/canActorAssignRole();ORG_ADMIN_MANAGEABLE_ROLES = ['supervisor','caseworker','viewer'].- The provisioning control plane (
/api/internal/provisioning/*) may only assign organization roles; a newly provisioned organization's primary admin isorg_admin.
Module-Based Access Control
In addition to roles, the CRM uses module-based access by organization tier (Basic, Standard, Premium, Enterprise). See Organization Modules. Network users bypass module checks.
Migrations
| File | Purpose |
|---|---|
20260920120000_role_model_00_audit.sql | Read-only report + audit gate |
20260920120001_role_model_01_remap.sql | organizations.is_network (Range Lab) + is_demo; staff in a non-network org → caseworker; demo → org_admin in a demo org; landlord deactivated; remap log |
20260920120002_role_model_02_constraint.sql | chk_profiles_role_org_scope |
20260920120003_role_model_03_profile_write_guard.sql | network-org invariant + authority-column trigger (portal self-signup allowed); is_network admin-only trigger |
There is deliberately no landlord table migration: public.properties
(read by RST) has an FK to landlords, so retiring landlords /
landlord_users / profiles.landlord_id is a coordinated shared-schema
change under RST's docs/SHARED_SCHEMA_COORDINATION.md (rule §6.10).
Use links in each imported doc to open its source.