Skip to content

RBAC

@nwire/rbac wraps CASL. Declare abilities per user, tag actions with a policy tuple, and the framework enforces.

Install

bash
pnpm add @nwire/rbac

Declare 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

MIT licensed.