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