RBAC
@nwire/rbac wraps CASL. Declare abilities per user, tag actions with a policy tuple, and the framework enforces.
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 { rbacPlugin } from "@nwire/rbac"
const app = await createApp({
modules: [submissions],
plugins: [rbacPlugin({ abilities })],
})Tag the action
ts
const gradeSubmission = defineAction({
name: "submissions.grade",
policy: ["grade", "Submission"],
schema: 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 @nwire/http).
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 (input, ctx) => {
const ability = abilityFromCtx(ctx)
const submission = await ctx.use(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