RBAC
Control who's allowed to do what. Declare each user's abilities, tag a handler with the permission it requires, and the framework checks it on every dispatch — before the handler runs, under every transport. @nwire/rbac does this on top of CASL.
Install
bash
pnpm add @nwire/rbacDeclare abilities
ts
import { defineAbility } from "@nwire/rbac"
export const abilities = defineAbility((user, { allow, deny }) => {
allow("read", "Submission")
if (user.role === "teacher") {
allow("grade", "Submission", { classId: { $in: user.classIds } })
}
if (user.role === "student") {
allow("submit", "Submission", { studentId: user.id })
}
deny("delete", "Submission")
})Plug it in
ts
import { createApp } from "@nwire/app"
import { forgePlugins } from "@nwire/forge"
import { rbacPlugin } from "@nwire/rbac"
const app = createApp({
appName: "submissions",
handlers: [gradeSubmission],
plugins: [...forgePlugins()],
})
.with(rbacPlugin({ abilities }))Tag the action
ts
const gradeSubmission = defineAction({
name: "submissions.grade",
policy: ["grade", "Submission"],
input: z.object({ submissionId: z.string(), grade: z.number() }),
emits: [SubmissionWasGraded],
handler: async ({ input }) => SubmissionWasGraded(input),
})When gradeSubmission is dispatched, the plugin checks user.can("grade", "Submission") against the loaded actor. If the check fails it throws ForbiddenError (mapped to 403 by the HTTP adopter, @nwire/koa).
Instance-level checks
For per-instance conditions ({ classId: { $in: ... } }), use subject(Submission, submission) to bind the actor data so CASL can match against the conditions.
ts
import { abilityFromCtx, subject } from "@nwire/rbac"
handler: async (ctx) => {
const { input } = ctx
const ability = abilityFromCtx(ctx)
const submission = await ctx.actor(Submission, input.submissionId)
if (ability.cannot("grade", subject("Submission", submission))) {
throw new ForbiddenError()
}
return SubmissionWasGraded(input)
}See also
- Auth — Logto — where
user.rolecomes from - Auth — better-auth — drop-in alternative IdP