RBAC
v0.2.0 replaced v0.1.x's lib/ownership.ts helpers with 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 v0.1.x rows. The 20260310000000_add_annotation_userid
migration introduced Annotation.userId for the v0.1.8 work; v0.2.0
adds createdByUserId and prefers it. 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 the v0.1.x explicit userId: request.user.id filters.
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 carries over from v0.1.x: 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)
}
}
v0.2.1 added these prechecks 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.