Skip to content

RBAC Tiers & Permissions

Linear issues:

Implementation PRs:

Related project:

Related feature specs:


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.

The redesigned system uses five layers of authorisation, evaluated from broadest to most granular:

LayerMechanismDetermines
1. System RolesystemRole on AuthContextWhich branch of defineAbilitiesFor() applies
2. Company TierCompanyTier enum on companyWhich features a company can access
3. Company Role + Permissionsrole + assignedPermissions on user membershipWhat 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 Lockingcannot() rules reading fieldMetadataWhich 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.

Changed:

  • isFunder: boolean + isVerified: booleanCompanyTier enum (standard, commissioner)
  • Old permissions array → role: 'admin' | 'user' + assignedPermissions: CompanyPermissionV2[]
  • JSON rule definitions with string interpolation → TypeScript AbilityBuilder (defineAbilitiesFor())
  • sync-roles pipeline → removed entirely
  • verifyCompany mutation → deprecated as a no-op
  • specialPermissions on OrgUser → systemRole field on OrgUser document (read by buildAuthContext() to populate AuthContext.systemRole)
  • Reporting access (client-side isFunder && isVerified check) → isCommissionerTier() utility

Stayed:

  • CASL as the runtime enforcement engine for data access (Layer 4)
  • Ory.sh for authentication
  • isOrphan flag on companies
  • fundedProjectIds array for funder-project relationships
  • companyId-based multi-tenant isolation

Two tiers, stored as lowercase strings in MongoDB:

TierDB ValueDescription
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.

The migration script (scripts/migrate-rbac-fields.ts) maps existing data:

Old ValueNew Tier
isFunder === truecommissioner
isFunder === false or absentstandard

The script is idempotent (skips documents where tier already exists) and supports --dry-run, --validate-only, --rollback, and --batch-size flags.

During the transition period, both isFunder and tier are maintained in sync:

  • Setting tier derives isFunder (commissioner → true, standard → false)
  • Setting isFunder derives tier (true → commissioner, false → standard)
  • The derived value always wins over raw input, preventing drift
  • Both fields are set in a single MongoDB $set operation, 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.

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

Commissioner-tier companies gain funder capabilities:

CapabilityCheck
Can fund projectstier === 'commissioner'
Can approve/reject projectstier === 'commissioner' + fundedProjectIds scope
Can access basic reportingVIEW_BASIC_REPORTS in assignedPermissions (tier-gated)
Can access advanced reportingVIEW_ADVANCED_REPORTS in assignedPermissions (tier-gated)
Can see funded projectsfundedProjectIds array on company

On the client, isCommissionerTier() replaces the old isFunder === true && isVerified === true double-check with a single company.tier === CompanyTier.Commissioner test.


Two roles, stored on each entry in the user’s companies[] array:

RoleValueDescription
Admin'admin'Full company management capabilities
User'user'Basic member access

Migration rule: permissions.includes('admin')role = 'admin', otherwise → role = 'user'.

Four assignable permissions, stored in assignedPermissions[] on each company membership:

PermissionDescription
MANAGE_CONTRIBUTORSCreate, update, delete contributors for the company
APPROVE_REJECT_PROJECTSApprove or reject project submissions
VIEW_BASIC_REPORTSAccess standard reporting UI
VIEW_ADVANCED_REPORTSAccess 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.

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.

CapabilityRequired Role
Create/manage projectsAdmin
Approve/reject projects (commissioner)Admin + APPROVE_REJECT_PROJECTS (both required)
Manage company usersAdmin
View projectsAll members
View contributor rolesAll members

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 simultaneously
  • accessType field resolver provides backwards-compatible return values (ADMIN, OWNER, FUNDER, BOTH, null)
  • myProjectAccess returns null (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 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
  • useFieldAccess hook 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 from FunderPrivateTab) is gated on hasPrivateAccess derived from privateCompanyDataFields visibility
  • ReleaseTab was fully deleted and replaced by field-level visibility controls on the main project form

Old FieldNew FieldMapping
company.isFunder === truecompany.tier = 'commissioner'Via migration script
company.isFunder === falsecompany.tier = 'standard'Via migration script
orgUser.companies[].permissions.includes('admin')orgUser.companies[].role = 'admin'Via migration script
Other permissionsorgUser.companies[].role = 'user'Via migration script
orgUser.companies[].assignedPermissionsAlways []Migrated empty; granted later

All migration steps have been completed and deployed to production.

  1. TEP-411: Add tier and role fields, run migration script
  2. TEP-412: Deploy TypeScript ability builder (replaces JSON rules)
  3. TEP-413: Migrate business logic from isFunder to tier
  4. TEP-414: Update client to use tier-based checks
  5. TEP-415: Remove old auth infrastructure (JSON rules, sync scripts, verifyCompany, specialPermissions)
  6. TEP-427: Deploy myProjectAccess query
  7. TEP-426: Deploy field-level visibility UI
  • isFunder and isVerified remain as deprecated GraphQL fields, derived from tier
  • accessType field resolver returns the same values (ADMIN, OWNER, FUNDER, BOTH) via getProjectRelationships()
  • CASL rules retain isFunder and isVerified in 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.


These criteria are retained for audit trail purposes. All items were completed across TEP-411 to TEP-460.

  • CompanyTier enum added with standard and commissioner values
  • Migration script maps isFundertier with dry-run and rollback support
  • OrgUserRole (admin/user) replaces permissions array
  • CompanyPermissionV2 enum with four assignable permissions
  • TypeScript defineAbilitiesFor() replaces JSON role definitions
  • sync-roles pipeline and JSON seed files removed
  • verifyCompany mutation deprecated as a no-op
  • specialPermissions field replaced by systemRole on OrgUser document
  • getProjectRelationships() determines owner/funder/systemAdmin/industryAdmin relationships
  • myProjectAccess query returns per-field visibility/editability/lock status
  • ProjectAccessMatrix defines access levels per relationship per field
  • Time-lock overlay applied after deliveryDate + 30 days
  • Field-lock overlay reads fieldMetadata and adds cannot() rules
  • Client useFieldAccess hook with graceful degradation
  • LockedFieldIndicator component for locked fields
  • PrivateTab gated on hasPrivateAccess
  • ReleaseTab removed
  • isFunder/isVerified retained as deprecated fields for backward compatibility
  • accessType resolver maintains backwards-compatible return values
  • Standard-tier company admins can create standard orphan companies (TEP-460 fix)

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

  • Parity verification between old JSON-based rules and new TypeScript ability builder across all actor archetypes
  • Test harness seed data updated to use tier and role fields instead of legacy isFunder/permissions
  • defineAbilitiesFor() branch coverage for all five system roles
  • ProjectAccessComputer with role, time-lock, and field-lock overlays
  • ProjectAccessMatrix field-level access for each relationship type
  • getProjectRelationships() with owner, funder, admin, and combined relationships
  • useFieldAccess graceful degradation (null access, query error)
  • isCommissionerTier() utility
  • Bruno rbac-contribute-flow suite: full commissioner/standard company lifecycle
  • Standard-tier company admin creating standard orphan company (passes) and commissioner orphan (blocked)
  • Migration script with --dry-run and --validate-only flags