RBAC Tiers & Permissions
References
Section titled “References”Linear issues:
- TEP-411: Add CompanyTier and company role fields to data model
- TEP-412: Replace CASL JSON rules with TypeScript ability builder and switchover
- TEP-413: Update business logic from isFunder to CompanyTier and simplify auth functions
- TEP-414: Update client permission model, guards, and queries for new auth system
- TEP-415: Remove old auth infrastructure and deprecated fields
- TEP-426: Implement field-level visibility, editability, and locking UI
- TEP-427: Implement myProjectAccess query for unified field-level access
- TEP-460: fix: subscriber companies cannot create orphan companies after RBAC v2 migration
Implementation PRs:
- tep-api#1165 — CompanyTier and OrgUser role fields (TEP-411)
- tep-configuration#81 — Configuration updates for tier model (TEP-411)
- tep-api#1169 — TypeScript ability builder and switchover (TEP-412)
- tep-api#1170 — Business logic migration to tier model (TEP-413)
- tep-client#687 — Client permission model update (TEP-414)
- tep-api#1178 — Remove old auth infrastructure (TEP-415)
- tep-api#1177 — myProjectAccess query (TEP-427)
- tep-client#690 — Field-level visibility UI (TEP-426)
- tep-api#1182 — Orphan company creation fix (TEP-460)
Related project:
Related feature specs:
- Feature 08: RBAC Field Visibility — requirements input to this redesign
Overview
Section titled “Overview”The RBAC redesign replaced the over-engineered, database-driven CASL JSON rule system with a simpler, fully TypeScript-defined authorisation model. The old system stored role definitions as JSON in tep-configuration, synced them to tep-api via a sync-roles pipeline, interpolated string variables at runtime, and loaded rules from the database on every request. The new system defines all authorisation logic in TypeScript using CASL’s AbilityBuilder, with no JSON files, no database storage, and no string interpolation.
Five-Layer Permission Model
Section titled “Five-Layer Permission Model”The redesigned system uses five layers of authorisation, evaluated from broadest to most granular:
| Layer | Mechanism | Determines |
|---|---|---|
| 1. System Role | systemRole on AuthContext | Which branch of defineAbilitiesFor() applies |
| 2. Company Tier | CompanyTier enum on company | Which features a company can access |
| 3. Company Role + Permissions | role + assignedPermissions on user membership | What a user can do within their company |
| 4. Data Access (CASL) | AbilityBuilder rules in defineAbilitiesFor() | Which entities and fields the user can read/write |
| 5. Field Locking | cannot() rules reading fieldMetadata | Which fields are locked by another company |
Layers 1–3 are checked via simple function calls. Layer 4 uses CASL with the AbilityBuilder. Layer 5 adds cannot() rules on top of Layer 4. For full implementation details, see Authorisation System Overview.
What Changed vs What Stayed
Section titled “What Changed vs What Stayed”Changed:
isFunder: boolean+isVerified: boolean→CompanyTierenum (standard,commissioner)- Old
permissionsarray →role: 'admin' | 'user'+assignedPermissions: CompanyPermissionV2[] - JSON rule definitions with string interpolation → TypeScript
AbilityBuilder(defineAbilitiesFor()) sync-rolespipeline → removed entirelyverifyCompanymutation → deprecated as a no-opspecialPermissionson OrgUser →systemRolefield on OrgUser document (read bybuildAuthContext()to populateAuthContext.systemRole)- Reporting access (client-side
isFunder && isVerifiedcheck) →isCommissionerTier()utility
Stayed:
- CASL as the runtime enforcement engine for data access (Layer 4)
- Ory.sh for authentication
isOrphanflag on companiesfundedProjectIdsarray for funder-project relationshipscompanyId-based multi-tenant isolation
Company Tiers
Section titled “Company Tiers”CompanyTier Enum
Section titled “CompanyTier Enum”Two tiers, stored as lowercase strings in MongoDB:
| Tier | DB Value | Description |
|---|---|---|
STANDARD | 'standard' | Default for production companies. No funder capabilities. |
COMMISSIONER | 'commissioner' | Funders and commissioners. Can fund projects, approve submissions, access advanced reporting. |
A speculative PROFESSIONAL tier was considered during design but explicitly excluded from the implementation.
Migration from isFunder
Section titled “Migration from isFunder”The migration script (scripts/migrate-rbac-fields.ts) maps existing data:
| Old Value | New Tier |
|---|---|
isFunder === true | commissioner |
isFunder === false or absent | standard |
The script is idempotent (skips documents where tier already exists) and supports --dry-run, --validate-only, --rollback, and --batch-size flags.
Dual-Field Sync
Section titled “Dual-Field Sync”During the transition period, both isFunder and tier are maintained in sync:
- Setting
tierderivesisFunder(commissioner → true, standard → false) - Setting
isFunderderivestier(true → commissioner, false → standard) - The derived value always wins over raw input, preventing drift
- Both fields are set in a single MongoDB
$setoperation, ensuring atomicity at the document level
The isFunder field is @deprecated in the GraphQL schema. isVerified was dropped as a separate authorisation gate — it is consolidated into the tier model.
isOrphan Interaction
Section titled “isOrphan Interaction”Orphan companies (no associated users) cannot perform actions that require a user context. Commissioner orphans cannot approve projects or access reporting because there is no user to act on their behalf. Standard orphan companies can be created by any standard-tier company admin (see TEP-460 below).
Funder Capability Checks
Section titled “Funder Capability Checks”Commissioner-tier companies gain funder capabilities:
| Capability | Check |
|---|---|
| Can fund projects | tier === 'commissioner' |
| Can approve/reject projects | tier === 'commissioner' + fundedProjectIds scope |
| Can access basic reporting | VIEW_BASIC_REPORTS in assignedPermissions (tier-gated) |
| Can access advanced reporting | VIEW_ADVANCED_REPORTS in assignedPermissions (tier-gated) |
| Can see funded projects | fundedProjectIds array on company |
On the client, isCommissionerTier() replaces the old isFunder === true && isVerified === true double-check with a single company.tier === CompanyTier.Commissioner test.
Company Roles & Permissions
Section titled “Company Roles & Permissions”OrgUserRole
Section titled “OrgUserRole”Two roles, stored on each entry in the user’s companies[] array:
| Role | Value | Description |
|---|---|---|
| Admin | 'admin' | Full company management capabilities |
| User | 'user' | Basic member access |
Migration rule: permissions.includes('admin') → role = 'admin', otherwise → role = 'user'.
CompanyPermissionV2
Section titled “CompanyPermissionV2”Four assignable permissions, stored in assignedPermissions[] on each company membership:
| Permission | Description |
|---|---|
MANAGE_CONTRIBUTORS | Create, update, delete contributors for the company |
APPROVE_REJECT_PROJECTS | Approve or reject project submissions |
VIEW_BASIC_REPORTS | Access standard reporting UI |
VIEW_ADVANCED_REPORTS | Access advanced reporting UI |
These permissions are tier-gated — a standard-tier company cannot meaningfully use VIEW_ADVANCED_REPORTS because the reporting features are scoped to commissioners. Initially, all migrated users have assignedPermissions = []; permissions are granted explicitly by company admins.
Capability Checks
Section titled “Capability Checks”Layers 1–3 are checked via the CASL ability instance returned by defineAbilitiesFor(). The function uses a switch (systemRole) with one branch per actor archetype (system_admin, industry_admin, org_user, contributor, service_account). Within each branch, can() and cannot() calls define the allowed operations.
The old hasPermission() utility was deleted. The equivalent check is now ability.can(action, subject, field) from the CASL MongoAbility instance.
Admin-Only vs All-Member Capabilities
Section titled “Admin-Only vs All-Member Capabilities”| Capability | Required Role |
|---|---|
| Create/manage projects | Admin |
| Approve/reject projects (commissioner) | Admin + APPROVE_REJECT_PROJECTS (both required) |
| Manage company users | Admin |
| View projects | All members |
| View contributor roles | All members |
Data Access
Section titled “Data Access”Data access is implemented via getProjectRelationships(), ProjectAccessMatrix, ProjectAccessComputer, and the myProjectAccess GraphQL query. For full implementation details — including the GraphQL schema, access matrix definitions, and overlay computation — see Authorisation System Overview: Layer 4 and myProjectAccess Query.
Design decisions:
getProjectRelationships()returns an array because a company can be both owner and funder simultaneouslyaccessTypefield resolver provides backwards-compatible return values (ADMIN,OWNER,FUNDER,BOTH,null)myProjectAccessreturnsnull(not an error) for users with no project relationship, preventing information leaks- When a user holds multiple relationships,
resolveAccessLevel()takes the most permissive level
Field Locking
Section titled “Field Locking”Field locking is enforced via CASL cannot() rules that read fieldMetadata. For the full implementation details and code examples, see Authorisation System Overview: Layer 5.
Design decisions:
- Locking company retains edit rights (no
cannot()rule added for their own locks) - System admins are exempt from all field locks
useFieldAccesshook provides O(1) field access lookup with graceful degradation (null access → permissive during loading; query error → fail-closed). The permissive-on-null behaviour is a client-side loading state optimisation only — the server-side CASL ability check remains the authoritative enforcement point.PrivateTab(renamed fromFunderPrivateTab) is gated onhasPrivateAccessderived fromprivateCompanyDataFieldsvisibilityReleaseTabwas fully deleted and replaced by field-level visibility controls on the main project form
Migration Path
Section titled “Migration Path”Data Migration
Section titled “Data Migration”| Old Field | New Field | Mapping |
|---|---|---|
company.isFunder === true | company.tier = 'commissioner' | Via migration script |
company.isFunder === false | company.tier = 'standard' | Via migration script |
orgUser.companies[].permissions.includes('admin') | orgUser.companies[].role = 'admin' | Via migration script |
| Other permissions | orgUser.companies[].role = 'user' | Via migration script |
orgUser.companies[].assignedPermissions | Always [] | Migrated empty; granted later |
Migration Sequence (Complete)
Section titled “Migration Sequence (Complete)”All migration steps have been completed and deployed to production.
- TEP-411: Add
tierandrolefields, run migration script - TEP-412: Deploy TypeScript ability builder (replaces JSON rules)
- TEP-413: Migrate business logic from
isFundertotier - TEP-414: Update client to use
tier-based checks - TEP-415: Remove old auth infrastructure (JSON rules, sync scripts,
verifyCompany,specialPermissions) - TEP-427: Deploy
myProjectAccessquery - TEP-426: Deploy field-level visibility UI
Backwards Compatibility
Section titled “Backwards Compatibility”isFunderandisVerifiedremain as deprecated GraphQL fields, derived fromtieraccessTypefield resolver returns the same values (ADMIN,OWNER,FUNDER,BOTH) viagetProjectRelationships()- CASL rules retain
isFunderandisVerifiedin read allowlists for backward compatibility with existing clients
TEP-460: Orphan Company Creation Regression
Section titled “TEP-460: Orphan Company Creation Regression”After the RBAC v2 migration, standard-tier company admins could not create standard orphan companies. The createOrphanCompany mutation had a hardcoded isSysOrIndustryAdmin guard that blocked all non-admin users, even though CASL field-level rules (cannot(['create'], 'Company', ['isFunder', 'tier'])) were sufficient to prevent commissioner-tier creation.
Fix: Removed the blanket guard and moved the cannot rule to the universal org_user section in defineAbilities, so future can('create', 'Company') grants cannot inadvertently bypass it.
Acceptance Criteria
Section titled “Acceptance Criteria”These criteria are retained for audit trail purposes. All items were completed across TEP-411 to TEP-460.
-
CompanyTierenum added withstandardandcommissionervalues - Migration script maps
isFunder→tierwith dry-run and rollback support -
OrgUserRole(admin/user) replaces permissions array -
CompanyPermissionV2enum with four assignable permissions - TypeScript
defineAbilitiesFor()replaces JSON role definitions -
sync-rolespipeline and JSON seed files removed -
verifyCompanymutation deprecated as a no-op -
specialPermissionsfield replaced bysystemRoleon OrgUser document -
getProjectRelationships()determines owner/funder/systemAdmin/industryAdmin relationships -
myProjectAccessquery returns per-field visibility/editability/lock status -
ProjectAccessMatrixdefines access levels per relationship per field - Time-lock overlay applied after
deliveryDate + 30 days - Field-lock overlay reads
fieldMetadataand addscannot()rules - Client
useFieldAccesshook with graceful degradation -
LockedFieldIndicatorcomponent for locked fields -
PrivateTabgated onhasPrivateAccess -
ReleaseTabremoved -
isFunder/isVerifiedretained as deprecated fields for backward compatibility -
accessTyperesolver maintains backwards-compatible return values - Standard-tier company admins can create standard orphan companies (TEP-460 fix)
Future Enhancements
Section titled “Future Enhancements”The following field visibility decisions are deferred design questions that do not block the current implementation. They are not yet reflected in the ProjectAccessMatrix:
- Greenlight date visibility — should production companies see the greenlight date? The Data Access Matrix has a question mark.
- Commissioner genre visibility — should production companies see the broadcaster’s internal genre classification?
- Industry admin field restrictions — should industry admins have write access to any project fields, or read-only for all?
- Contributor role correction — can production companies correct contributor role assignments made by the broadcaster?
- System admin lock override — should system admins be able to override field locks? (Currently exempt from all locks.)
Testing Requirements
Section titled “Testing Requirements”Test Harness
Section titled “Test Harness”- Parity verification between old JSON-based rules and new TypeScript ability builder across all actor archetypes
- Test harness seed data updated to use
tierandrolefields instead of legacyisFunder/permissions
Unit Tests
Section titled “Unit Tests”defineAbilitiesFor()branch coverage for all five system rolesProjectAccessComputerwith role, time-lock, and field-lock overlaysProjectAccessMatrixfield-level access for each relationship typegetProjectRelationships()with owner, funder, admin, and combined relationshipsuseFieldAccessgraceful degradation (null access, query error)isCommissionerTier()utility
E2E Tests
Section titled “E2E Tests”- Bruno
rbac-contribute-flowsuite: full commissioner/standard company lifecycle - Standard-tier company admin creating standard orphan company (passes) and commissioner orphan (blocked)
- Migration script with
--dry-runand--validate-onlyflags