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 { createApp } from "@nwire/app"
import { forgePlugins } from "@nwire/forge"
import { rbacPlugin } from "@nwire/rbac"

const app = createApp({
  appName: "submissions",
  handlers: [/* … */],
  plugins: [...forgePlugins({ /* actors, projections, … */ })],
})
  .with(rbacPlugin({ buildAbility: 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

MIT licensed.