Skip to content

06 — Permissions

Not everyone in an org should be able to do everything. This chapter adds role-based access control (RBAC): owners can delete projects, members can only view.

Every org member has a role: owner or member. The role is stored on the membership record and surfaced on ctx.org.member.role inside orgProcedure.

CruzJS uses permission strings like projects:delete. You define which roles have which permissions, then check them in your procedures.

Create packages/core/src/tasks/tasks.permissions.ts:

import { definePermissions } from '@cruzjs/core';
export const TASK_PERMISSIONS = definePermissions({
'projects:create': ['owner', 'member'],
'projects:delete': ['owner'],
'tasks:create': ['owner', 'member'],
'tasks:delete': ['owner', 'member'],
});
packages/core/src/tasks/tasks.trpc.ts
import { requirePermission } from '@cruzjs/core';
import { TASK_PERMISSIONS } from './tasks.permissions';
deleteProject: t.orgProcedure
.input(z.object({ id: z.string() }))
.mutation(({ ctx, input }) => {
requirePermission(ctx.org, 'projects:delete', TASK_PERMISSIONS);
return this.service.deleteProject(input.id, ctx.org.id);
}),

requirePermission throws a 403 TRPCError if the caller’s role doesn’t have the permission. No if statement needed in your service.

  1. Sign in as the org owner. Try deleting a project — it works.
  2. Sign in as a member. Try deleting the same project — you get a 403.

The check happens server-side. The client cannot bypass it by calling the endpoint directly.

Hide the delete button for members:

// In your React component
const { data: member } = trpc.org.currentMember.useQuery();
{member?.role === 'owner' && (
<button onClick={() => deleteProject.mutate({ id: project.id })}>
Delete project
</button>
)}

Always enforce on the server too — the UI check is just UX, not security.

  • Defined permission strings for projects:delete
  • Used requirePermission to enforce roles server-side
  • Tested the access control with two accounts

Next: Chapter 07 — Real-Time