Permissions System Documentation¶
This document explains how the FarmCove Delta permissions system works, including role-based access control (RBAC) and project-specific permissions.
Overview¶
The permissions system is designed around the concept that users have different roles in different projects. A user might be a Producer in one project but only a Crew Member in another. This affects what they can see and do within each project context.
Core Concepts¶
1. Permissions¶
A permission is an atomic unit of access control. Each permission follows the pattern:
module:action:scope
Examples:
budget:view:assigned- Can view budgets for assigned projectsbudget:edit:all- Can edit all budgetstransaction:create- Can create new transactions
2. Project Roles¶
Roles define a set of permissions that are commonly grouped together. Standard roles include:
- Producer: Full project access
- Line Producer: Budget and schedule management
- Production Accountant: Financial management
- Coordinator: General support access
- Department Head: Department-specific access
- Crew Member: Basic read-only access
3. Project Context¶
Permissions are evaluated within the context of a specific project. A user's access changes when they switch between projects.
How It Works¶
User Journey¶
- User logs in → Sees only projects they're assigned to
- User selects a project → System loads their role for that project
- System calculates permissions → Based on their role in that specific project
- UI adapts → Menu items and features show/hide based on permissions
Data Flow¶
User Account
↓
Project Team Member (links user to project with a role)
↓
Project Role (e.g., Producer, Line Producer)
↓
Role Permissions (what that role can do)
↓
Menu/Feature Access
Database Schema¶
Key Tables¶
- permissions: Defines all available permissions
permission_key: Unique identifier (e.g., 'budget:view:all')description: Human-readable description-
module: Feature area (Projects, Budgets, etc.) -
permission_roles: Defines available roles
name: Role name (Producer, Line Producer, etc.)-
description: What this role typically does -
user_accesses: Links users to projects with permissions
user_id: The userproject_id: The projectgrant_id: Their role or permission in this project-
status: Active, Invited, or Revoked -
permission_role_links: Maps permissions to roles
permission_role_id: The role-
permission_id: The permission granted -
menu_items: Navigation items
required_permission_key: Permission needed to see this menu
Permission Evaluation¶
The Query¶
When checking if a user can access a menu item in a project:
-- Function: get_user_project_menu
-- Returns all menu items with access status for a user in a project
WITH user_permissions AS (
-- Get all permissions for user's role in this project
SELECT DISTINCT p.permission_key
FROM user_accesses ua
JOIN permission_role_links prl
ON ua.grant_id = prl.permission_role_id
JOIN permissions p
ON prl.permission_id = p.id
WHERE ua.user_id = $user_id
AND ua.scope_id = $project_id
AND ua.scope_type = 'project'
AND ua.grant_type = 'role'
AND ua.status = 'Active' -- illustrative only; see note below
)
SELECT
mi.*,
CASE
WHEN mi.required_permission_key IS NULL THEN true -- No permission required
WHEN EXISTS (
SELECT 1 FROM user_permissions up
WHERE up.permission_key = mi.required_permission_key
) THEN true -- User has required permission
ELSE false -- User lacks permission
END as can_access
FROM menu_items mi
WHERE mi.is_active = true;
The
status = 'Active'filter above is a simplification. The real permission-resolution view (v_user_permissions, consumed bycaller_accessible_project_idsandcaller_has_permission) admits statusActiveORInvited— an invited user resolves permissions before accepting — and additionally filters on expiry (expires_atin the future or NULL) and, where aproject_relationship_idis present, the relationship being active (access_statusinActive/Invited). This is the "permission-resolution" contract; it is deliberately looser than the sensitive-data clearance contract (get_user_clearance), which requires statusActiveonly and also checksrevoked_at IS NULL. See "Access primitives & semantic contracts" below.
Example Scenarios¶
Scenario 1: Multi-Project User¶
- Sarah is a Producer in "Project Alpha"
- Sarah is a Crew Member in "Project Beta"
When Sarah is viewing Project Alpha:
- ✅ Can see Budgets menu (has
budget:view:all) - ✅ Can see Transactions menu (has
transaction:view:all) - ✅ Can edit project settings (has
project:edit:all)
When Sarah switches to Project Beta:
- ❌ Cannot see Budgets menu (lacks
budget:view:assigned) - ❌ Cannot see Transactions menu (lacks
transaction:view:assigned) - ✅ Can see Schedule (has
schedule:view)
Scenario 2: Permission Inheritance¶
The Line Producer role includes:
- All budget permissions
- All schedule permissions
- View-only transaction permissions
This is defined in permission_role_links table.
Menu System Integration¶
Menu Visibility¶
Each menu item has an optional required_permission_key. The menu is visible only if:
- No permission is required (
required_permission_keyis NULL), OR - The user has the required permission in the current project or organisation context
Dynamic Navigation¶
The app requests the user's menu for the current project:
// App code
const menuItems = await supabase.rpc('get_user_project_menu', {
p_user_id: currentUser.id,
p_project_id: currentProject.id,
});
// Only show accessible items
const visibleMenuItems = menuItems.filter((item) => item.can_access);
Security Considerations¶
Row Level Security (RLS)¶
All permission-related tables have RLS enabled with these best practices:
- Users can only see projects they're members of
- Permission definitions are read-only for regular users
- Service role bypasses RLS entirely (no need for explicit policies)
- Always use
(SELECT auth.uid())and(SELECT auth.role())in RLS policies to prevent per-row re-evaluation - Never use
FOR ALLin RLS policies - create separate policies for SELECT, INSERT, UPDATE, DELETE - Use
EXISTSinstead ofINfor subqueries in RLS policies for better performance - Always create indexes for foreign key columns used in RLS policies
Access primitives & semantic contracts¶
RLS policies and masking views compose a small set of access-primitive functions. Each carries a semantic contract (named in its DB COMMENT) that must not be confused with the others:
- Permission-resolution (caller identity).
caller_accessible_project_ids(text[], boolean)— set-based, returns the projects the caller has access to (bounded-capable in policies, see below).caller_has_permission(text[], uuid, boolean)— per-project probe (per-row when handed a row column). Both resolve viav_user_permissions: status Active OR Invited, not expired, relationship (when present) active. - Sensitive-data clearance.
get_user_clearance(scope, id)(caller) andget_user_clearance_for_user(user_id, scope, id)(explicit user, forservice_rolepaths). Stricter than permission resolution: status Active only, not expired,revoked_at IS NULL, roleis_active. Never interchangeable with the permission-resolution contract — do not substitute Active+Invited permission access where Active-only clearance is required, or vice versa. - Scope. Project-only vs organisation→project cascade is always explicit, never implied. The permission-resolution helpers take a boolean arg (
p_check_org_cascade); the clearance functions take an explicitscope_type+scope_idpair; the visibility helpers resolve eachsensitive_rulesrow at that rule’s own scope. - Permission requirement (permission-resolution helpers only). An empty key array means "any project access"; a non-empty array means "hold at least one of these specific keys".
- Identity. Caller (
auth.uid()) vs an explicit user (…_for_user, or the 4th arg ofrow_is_visible_to_caller) — the explicit-user variants exist forservice_rolepaths whereauth.uid()is NULL. - Project membership (caller identity).
caller_member_project_ids()(DEV-781) — set-based, the projects where the caller holds ANYuser_accessesrow withscope_type='project',status='Active'(never Invited), not expired. A third contract: looser than clearance (norevoked_at/ role checks) and stricter than permission resolution (no Invited) with no relationship-status or grant-join filters. It mirrors the formerproject_relationshipsSELECT-policyEXISTSexactly. Do not substitute it for either of the other contracts. - Sensitive-data restriction set (caller identity, own-row rules only).
private.caller_restricted_project_relationship_ids()(DEV-781) — the set-based sensitivity primitive Story 3 deferred: theproject_relationshipsids whose ownsensitive_rulesrow the caller's Active-only clearance does not satisfy (<@, all keys). Bounded by rule count; ancestor-cascade rules stay with per-rowrow_is_visible_to_caller. Lives in the non-APIprivateschema — its raw output enumerates cross-tenant restricted-record ids, so it must never be PostgREST-exposed.
Permission-vs-clearance asymmetries (known divergences — follow-up ticket recommended). Two ways a grant can be admitted by permission resolution while being rejected by clearance, both pinned by tests/07_access_primitive_semantics.sql:
revoked_at:v_user_permissionsdoes not checkrevoked_at; it relies on the grant'sstatusbeing flipped toRevoked. The clearance functions do checkrevoked_at IS NULL. A row leftstatus = 'Active'with a non-nullrevoked_atstill grants permissions but not clearance.- Role
is_active:v_user_permissionsresolves role-derived keys throughpermission_role_linkswithout joiningpermission_roles, so a grant referencing a deactivated role still yields its permissions. The clearance functions joinpermission_rolesand filteris_active = true, so the same grant yields no clearance.
Close with a follow-up ticket (align v_user_permissions with the stricter checks, or guarantee status/role lifecycle flips keep the fields in lockstep) — do not change either side inside performance work.
Bounded evaluation in policies. In SELECT policies scoping many rows, the set-based primitives (caller_accessible_project_ids(...), caller_member_project_ids(), private.caller_restricted_project_relationship_ids()) must be called in the bounded = ANY (SELECT unnest(fn(...))) form so its evaluation is independent of row count (once per executor process; parallel workers may each build the subplan) rather than re-executed per row (STABLE alone does not guarantee this). See .claude/rules/rls-policies.md § "Bounded Access-Primitive Evaluation".
Permission Checks¶
Always validate permissions server-side:
- API Layer: Check permissions before processing requests
- Database Layer: RLS policies enforce access control
- UI Layer: Hide/show features (convenience, not security)
Least-privilege grant invariant (invite / grant-access flow)¶
Granting access to a relationship (createProjectRelationshipWithPermissions,
grantAccessToExistingRelationship, updateRelationshipPermissions) runs under
a service-role client with RLS bypassed, so the action layer — not RLS — is
the enforcement point. Two checks gate every write, both before the
service-role escalation in the withRelationshipPermissionGate /
withResolvedRelationshipPermissionGate wrappers (services/project/actions.ts):
- Membership: the caller must hold
project_relationship:can_inviteon the target project (assertPermission). - Subset: the caller may only grant roles/permissions that are a subset of
what they themselves hold on that project. A holder of
{view, can_invite}cannot assignproject:admin,payment:process, etc. — so inviting a colleague can never escalate any account (including the caller's own) above the inviter's level. A grant that exceeds the caller's set is rejected atomically (nothing written) with the non-leaky message"You can only grant permissions you hold on this project".
Key rules of the subset check:
- The caller's held set is resolved via
getUserProjectPermissions(ctx().userId, projectId)— the caller's verified id from the request context (never a client-supplied id), consistent with worker-safe gated flows. - The comparison is project-scope keys only (
ScopeType.PROJECT). A project owner legitimately does not hold some organisation-scope keys that assignable roles carry (e.g. theproject:adminrole linksorganisation:edit), so an all-scope comparison would falsely block owners. Scoping to project keys keeps owners/full-holders behaviourally unchanged while still blocking real escalation. All these grants are written withscope_type: PROJECTanyway. - The auto-appended
organisation:viewerrole is exempt by construction: it is added server-side (includeOrgViewerRoleincreateUserAccessRecordsForRelationship), separate from the caller-supplied payload, so it never enters the checked set. - The pure predicate lives in
utils/permissions.ts(assertGrantWithinGranterPermissions+ the scope-resolution helpers) and is unit-tested in isolation. The UI mirrors the same rule for UX — the picker disables (with a tooltip) roles/permissions the caller can't assign (isRoleAssignable/isPermissionAssignable) — but the server check is the real gate.
Common Permission Patterns¶
1. Hierarchical Permissions¶
view:allimpliesview:assignededit:allimpliesedit:assignededitusually impliesview
2. Module-Based Grouping¶
Permissions are grouped by feature area:
- Projects: Create, view, edit, delete projects
- Budgets: Create, view, edit, approve budgets
- Transactions: Create, view, edit, approve transactions
- Scripts: Upload, view, breakdown scripts
- Schedules: Create, view, edit schedules
3. Scope Modifiers¶
:all- Access to all items in the module:assigned- Access only to items in assigned projects- (no modifier) - Global permission
Sensitive Data Permissions¶
Six permission keys gate visibility and mutation of sensitive records and fields. The four scope-aligned :view/:mark keys gate whole-row visibility on entities, project_relationships, budget_headers, budget_items (and everything derived from them — transactions, payments, approvals, attachments). The two scope-agnostic field-category keys (:view_pii, :view_payment_details) gate column-level masking on rows the caller can already see. Sensitivity is enforced primarily at the RLS layer; the keys here govern both the read-side carve-outs and the write-side toggle.
| Key | Scope | What it controls |
|---|---|---|
sensitive_data:project:view |
Project | See sensitive project_relationships, budget_headers, budget_items, and everything derived from them within that project (transactions, payments, approvals, attachments). Sensitivity is recorded as sensitive_rules rows whose required_permissions must be a subset of the caller's project clearance. |
sensitive_data:project:mark |
Project | Create / update / delete sensitive_rules rows for project-scoped records (project_relationships, transactions, payments, budget_headers, budget_items). Implies :project:view. Enforced inline by sensitive_rules policies via get_user_clearance('project', project_id). |
sensitive_data:organisation:view |
Organisation | See sensitive entity rows in that organisation and everything derived from them. Required-permission check resolves against organisation scope via get_user_clearance('organisation', org_id). |
sensitive_data:organisation:mark |
Organisation | Create / update / delete sensitive_rules rows for entity records. Implies :organisation:view. Enforced inline by the sensitive_rules INSERT/UPDATE/DELETE policies via get_user_clearance('organisation', organisation_id). |
sensitive_data:view_pii |
Scope-agnostic | See PII fields (phone, email) on visible sensitive rows. Implied by both :project:view and :organisation:view. Used by the masking views (v_entities, v_project_relationships) — when the caller's clearance contains this key, the CASE block returns the unmasked column value. |
sensitive_data:view_payment_details |
Scope-agnostic | See payment-detail fields (IBAN, bank account, SWIFT, bank name) on visible sensitive rows. Same shape as :view_pii, different field set. |
Implies chain (statically expanded in the role-binding seed):
:project:mark→:project:view,:view_pii,:view_payment_details:organisation:mark→:organisation:view,:view_pii,:view_payment_details:project:view→:view_pii,:view_payment_details:organisation:view→:view_pii,:view_payment_details
The expansion is done at grant time by the Phase 1 migration with an inline INSERT INTO permission_role_links ... ON CONFLICT DO NOTHING covering the seed roles. RLS reads a flat permission set — no runtime closure walk.
Why :mark implies :view: A user who can hide rows must also be able to verify the hide happened (and unhide later). Splitting the read/write gates would create a footgun where someone marks a row sensitive and immediately loses access to confirm it.
Why :view implies the field-category keys: A user who can see the whole sensitive row can already read every field on it. Splitting the field-category gates from row-level view would be operationally meaningless — there's no scenario where a user has full row visibility but can't see a specific column. The field-category keys exist to support refined grants in the future (e.g., a future role that can see sensitive rows but with PII masked), but today they're always granted together with :view.
Default role assignments: The project trio (:project:mark + :project:view + the two field keys) is bound to project:owner and project:admin. The organisation trio (:organisation:mark + :organisation:view + the two field keys) is bound to organisation:owner. None of these are granted to other built-in roles — sensitive data is opt-in.
Where they are checked:
- RLS policies on every gateable table call
row_is_visible_to_caller(<own_type>, <pk>, <ancestors_jsonb>)(DEFINER, STABLE). The helper resolves the rule's scope fromsensitive_rules.organisation_id/project_idand consultsget_user_clearance('organisation' | 'project', ...)accordingly. When nosensitive_rulesrow matches the record or any ancestor, the predicate is vacuously TRUE. - Masking views (
v_entities,v_project_relationships,v_transactions,v_payments,v_budget_headers,v_budget_items, the 3 approval views) embed CASE blocks that check whether the field's required key fromsensitive_rules.field_required_permissionsis in the caller's clearance:WHEN field_required_permissions ? '<col>' AND NOT (key = ANY(get_user_clearance(...))) THEN NULL ELSE column END AS column. - Server-side gates (approval routing, digest cron, AI processing) call
row_is_visible_to_caller(record_type, record_id, '[]'::jsonb, recipient_user_id)— the 4-arg form switches the internal lookup toget_user_clearance_for_user(recipient_user_id, ...)because service_role has NULLauth.uid(). - Approval router (
services/approval/base.ts) reads the record's effectivesensitive_required_permissionsviagetRecordRequiredPermissions(recordType, recordId)and filters tier approvers bygetUserClearanceForUser(user_id, 'project', projectId)at submission time. Tiers with zero cleared approvers are stamped Skipped with a sensitivity reason; if no tier is routable the request falls through to soft approval (also clearance-filtered) and ultimately auto-approves with the routing failure recorded in the request's reason. When a tier completes, the router re-evaluates the upcoming tiers' clearance before activating them — instances whose approver lost clearance since submission get Skipped, and a fully-uncleared upcoming chain auto-approves. - UI reads the keys from
usePermissions().hasPermission(PERMISSIONS.SENSITIVE_DATA_*)to decide whether to render the Manage Access button or per-field switches in the Manage Access dialog. Hiding controls is a UX convenience — the DB enforces the real gate inline in thesensitive_rulesINSERT/UPDATE/DELETE policies, which callget_user_clearance(scope, scope_id)and assert that the resulting array containssensitive_data:<scope>:mark.
To extend the sensitive-data system (new sensitive-eligible table, new restrictable field), see ADDING_SENSITIVE_DATA.md.
Best Practices¶
- Principle of Least Privilege: Users get only the permissions they need
- Role-Based Assignment: Use roles for common permission sets
- Project Isolation: Permissions don't leak between projects
- Audit Trail: Track who granted permissions and when
- Regular Review: Periodically review user access
Future Enhancements¶
Direct Permissions (Implemented)¶
The system supports granting specific permissions directly to a user at the project or organisation level, in addition to role-based permissions.
Time-Based Permissions (Implemented)¶
Permissions that expire after a certain date (useful for temporary crew) are implemented and enforced. user_accesses.expires_at holds the optional expiry; both the permission-resolution path (v_user_permissions → caller_accessible_project_ids / caller_has_permission) and the sensitive-data clearance functions (get_user_clearance / get_user_clearance_for_user) exclude grants whose expires_at is in the past. An expired grant stops conferring both permissions and clearance without any status change.
Delegation (Planned)¶
Allow users to delegate some of their permissions to others temporarily.
Troubleshooting¶
User Can't See Expected Menu Item¶
- Check their role in the current project
- Verify the role has the required permission
- Ensure the menu item is active
- Confirm they're viewing the correct project
Permission Changes Not Reflected¶
- App caches menu structure briefly
- Force refresh or switch projects and back
- Check that user_accesses status is 'Active'
Related Documentation¶
- Database Schema Index - Per-domain schema docs
- Permissions Schema - Permissions tables in detail
- Menu System - menu_items configuration and Default Menu Structure
- RLS Policies - Base RLS patterns and conventions