Skip to main content

RBAC

Authorization runs through a CASL ability framework backed by a database permission table. Every authenticated request carries a per-user Ability instance built from the user's roles plus the RolePermission matrix. Routes ask the ability what the user can do and CASL compiles the answer into either a boolean (for class-level checks) or a Prisma WHERE clause (for query-time scoping).

The pieces​

RolePermission (database table)        the policy matrix
|
v
defineAbilitiesFor(userId, roles, ...) build ability rules
|
v
PrismaAbility ability.can(action, subject)
| accessibleBy(ability, action)
v
@casl/prisma MongoQuery -> Prisma where

server/src/lib/abilities.ts builds the AppAbility. The middleware in server/src/middleware/abilities.ts attaches it to the request as request.ability. Routes call request.ability.can(...) for instance-level checks and accessibleBy(request.ability, action) for list filters.

Actions and subjects​

The Actions union enumerates every verb the system understands:

create | read | update | delete | share | export | assign
manage_members | fork | review | manage

manage is CASL's "all actions" wildcard. The Subjects are exactly the Prisma ModelName values used by the application:

Annotation | Claim | Persona | WorldState | Video
VideoSummary | Project | UserGroup | User

Routes that hand a Prisma row to subject('Annotation', row) from @casl/ability get instance-level evaluation that uses the row's ownership column.

Ownership columns​

Different tables carry ownership in different fields. CASL's MongoQuery conditions match against the actual Prisma columns, so the ability builder picks the right column per model:

Persona.userId
WorldState.userId
Annotation.createdByUserId (legacy userId still populated; backfill copies it)
VideoSummary.createdBy
Claim.createdBy
UserGroup.createdBy
Project.ownerUserId

The 20260415000000_backfill_rbac_ownership migration populated these columns for historical rows. The 20260221000000_add_projects_groups_rbac migration introduced Annotation.createdByUserId; the later 20260310000000_add_annotation_userid migration added Annotation.userId (backfilled from persona.userId) as a compat column, and CASL prefers createdByUserId. All write paths populate createdByUserId / createdBy from the authenticated session, never from the request body.

ownOnly​

RolePermission.ownOnly is a boolean flag on each row. When true, the rule applies only to resources the user owns:

if (perm.ownOnly) {
rules.push({ action, subject, conditions: { [ownField]: userId } })
} else {
rules.push({ action, subject })
}

Production seeds in server/prisma/seed-permissions.ts set ownOnly: true for write actions on content types (annotation, summary, claim, persona, world_state) under the annotator project role. Reads are never ownOnly at the project scope: members of a project can read every annotation in it. The reseedOwnershipBaseline test helper at server/test/integration/_rbac-baseline.ts mirrors this in suites that test ownership: it wipes the test-helper's blanket-grant rows and seeds ownOnly: true rows for every content action under the user system role, so CASL's per-row condition is what gates the test rather than an unconditional grant.

Query-time scoping​

For listing endpoints, the route uses accessibleBy from @casl/prisma to compile the ability rules into a Prisma WHERE clause:

const where = accessibleBy(request.ability, 'read').Annotation
const rows = await prisma.annotation.findMany({ where })

This replaces explicit userId: request.user.id filters in the route handler. The clause includes the union of every applicable rule's condition, so a user who is both a project member (read all annotations in the project) and a global owner (read all own annotations across all projects) gets both sets in one query.

Instance-time checks​

For mutations on a known row, the route fetches the row, hands it to subject(...), and asks the ability:

import { subject } from '@casl/ability'

const annotation = await prisma.annotation.findUnique({ where: { id } })
if (!annotation || !request.ability.can('update', subject('Annotation', annotation))) {
throw new NotFoundError('Annotation', id)
}

The NotFoundError-on-deny pattern is deliberate: the goal is to avoid confirming the existence of records the requester cannot see.

Foreign-id precheck on create​

Some create endpoints accept a foreign-resource id in the request body (for example, personaId on POST /api/annotations). The generic create Annotation rule does not look at the supplied personaId, so the route adds an explicit precheck:

if (body.personaId) {
const persona = await prisma.persona.findUnique({ where: { id: body.personaId } })
if (!persona || !request.ability.can('read', subject('Persona', persona))) {
throw new NotFoundError('Persona', body.personaId)
}
}

These prechecks run on POST /api/annotations (personaId), POST /api/videos/:videoId/detect (personaId), POST /api/summaries/:summaryId/claims (parent summary), and a defense-in-depth read-on-parent check on GET /api/summaries/:summaryId/claims.

Caching and invalidation​

buildAbilities keeps two caches:

  • A global RolePermission cache with a five-minute TTL fallback.
  • A per-user ability cache keyed by userId. No TTL; explicit invalidation only.

Every membership change, role change, system-role change, and RolePermission edit must call the matching invalidation helper so the change takes effect on the next request:

invalidatePermissionCache()        // RolePermission edits (clears all)
invalidateUserAbilities(userId) // single-user changes
invalidateGroupMembers(groupId) // group-scope role/perm changes
invalidateProjectMembers(projectId)// project-scope role/perm changes

Missing one of these is a silent vulnerability: a removed user keeps their access until the cache TTL expires. Every mutation route in routes/groups.ts, routes/projects.ts, and routes/admin-permissions.ts calls the corresponding helper.

See also​