Authorization
CruzJS uses role-based access control (RBAC) scoped to organizations. Every org member has a role, and each role maps to a set of permissions. The PermissionService and requirePermission middleware enforce access checks in tRPC routers and React Router loaders/actions.
Four built-in roles form a hierarchy:
| Role | Level | Description |
|---|---|---|
OWNER | Highest | Full control. All permissions (*). Cannot be removed unless ownership is transferred. |
ADMIN | High | Manages org settings, members, billing, and all resources. Cannot delete the org. |
MEMBER | Standard | Read access to org, read/write access to resources (e.g., pipelines). |
VIEWER | Lowest | Read-only access to org and resources. |
Roles are stored as the role column on the orgMembers table. The org creator is automatically assigned OWNER.
Permission format
Section titled “Permission format”Permissions follow a resource:action pattern:
org:read -- View organization detailsorg:write -- Update organization settingsorg:delete -- Delete the organizationmember:read -- View org membersmember:write -- Invite/add members, change rolesmember:delete -- Remove membersbilling:read -- View billing/subscription infobilling:write -- Manage billing/subscriptionpipeline:read -- View pipelinespipeline:write -- Create/update pipelinespipeline:delete -- Delete pipelinesRole-permission mapping
Section titled “Role-permission mapping”The mapping is defined in @cruzjs/core and can be imported for client-side checks:
import type { Permission, OrgRole } from '@cruzjs/core/orgs/org.models';import { rolePermissions } from '@cruzjs/core/orgs/org.models';
export const rolePermissions: Record<OrgRole, Permission[] | '*'> = { OWNER: '*', // All permissions ADMIN: [ 'org:read', 'org:write', 'member:read', 'member:write', 'member:delete', 'billing:read', 'billing:write', 'pipeline:read', 'pipeline:write', 'pipeline:delete', ], MEMBER: [ 'org:read', 'member:read', 'pipeline:read', 'pipeline:write', ], VIEWER: [ 'org:read', 'pipeline:read', ],};OWNER receives the special '*' value, which causes hasPermission to return true for any permission check.
Checking permissions in tRPC routers
Section titled “Checking permissions in tRPC routers”Using requirePermission in orgProcedure
Section titled “Using requirePermission in orgProcedure”The most common pattern is using orgProcedure (which establishes org context) combined with requirePermission:
import { orgProcedure, router } from '@cruzjs/core/trpc/context';import { requirePermission } from '@cruzjs/start/orgs/auth.utils';
export const pipelineRouter = router({ create: orgProcedure .input(createPipelineSchema) .mutation(async ({ ctx, input }) => { // ctx.org is set by orgProcedure await requirePermission(ctx.org, 'pipeline:write');
// User has permission -- proceed const service = ctx.container.get<PipelineService>(PipelineService); return service.create(ctx.org.org.orgId, input); }),
delete: orgProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await requirePermission(ctx.org, 'pipeline:delete'); // ... }),});requirePermission throws a 403 Response if the user lacks the required permission.
Requiring multiple permissions
Section titled “Requiring multiple permissions”import { requireAnyPermission, requireAllPermissions,} from '@cruzjs/start/orgs/auth.utils';
// OR logic -- user needs at least one of theseawait requireAnyPermission(ctx.org, ['org:write', 'member:write']);
// AND logic -- user needs all of theseawait requireAllPermissions(ctx.org, ['billing:read', 'billing:write']);Checking permissions in React Router loaders
Section titled “Checking permissions in React Router loaders”For server-side permission checks in loaders/actions:
import { requireSession } from '@cruzjs/core/shared/middleware/session.middleware';import { requireOrgContext } from '@cruzjs/core/shared/middleware/org-context.middleware';import { requirePermission } from '@cruzjs/start/orgs/auth.utils';
export async function loader({ request, params }: LoaderFunctionArgs) { const auth = await requireSession(request); const orgContext = await requireOrgContext(request, params, auth); await requirePermission(orgContext, 'billing:read');
// User has billing:read permission -- load data}Using PermissionService directly
Section titled “Using PermissionService directly”For more complex permission logic, inject PermissionService:
import { PermissionService } from '@cruzjs/start/orgs/permission.service';
const permissionService = container.get<PermissionService>(PermissionService);
// Check single permissionconst canEdit = await permissionService.hasPermission(userId, orgId, 'org:write');
// Check any permission (OR)const canManage = await permissionService.hasAnyPermission(userId, orgId, [ 'member:write', 'member:delete',]);
// Check all permissions (AND)const canBill = await permissionService.hasAllPermissions(userId, orgId, [ 'billing:read', 'billing:write',]);
// Get the user's role directlyconst role = await permissionService.getUserRole(userId, orgId);
// Convenience checksconst isOwner = await permissionService.isOrgOwner(userId, orgId);const isAdminOrOwner = await permissionService.isOrgAdminOrOwner(userId, orgId);Checking permissions in the UI
Section titled “Checking permissions in the UI”The auth.session query returns each org with the user’s role. Use this to conditionally render UI elements:
function OrgSettings() { const { data } = trpc.auth.session.useQuery(); const currentOrg = data?.organizations.find((o) => o.isCurrent);
// Only OWNER and ADMIN have org:write const canEditOrg = currentOrg?.role === 'OWNER' || currentOrg?.role === 'ADMIN';
return ( <div> {canEditOrg && <Button onClick={openSettings}>Edit Settings</Button>} </div> );}For reusable permission checks on the client, create a helper:
import { rolePermissions, ALL_PERMISSIONS } from '@cruzjs/core/orgs/org.models';import type { OrgRole, Permission } from '@cruzjs/core/orgs/org.models';
export function hasPermission(role: OrgRole, permission: Permission): boolean { const perms = rolePermissions[role]; if (perms === ALL_PERMISSIONS) return true; return perms.includes(permission);}Adding custom permissions
Section titled “Adding custom permissions”To add new permissions for your domain resources, extend the permission configuration in your project. The Permission type and rolePermissions mapping from @cruzjs/pro can be augmented:
import type { OrgRole } from '@cruzjs/core/orgs/org.models';
// Define your custom permissionsexport type AppPermission = | 'org:read' | 'org:write' | 'org:delete' | 'member:read' | 'member:write' | 'member:delete' | 'billing:read' | 'billing:write' | 'pipeline:read' | 'pipeline:write' | 'pipeline:delete' // Add your custom permissions | 'document:read' | 'document:write' | 'document:delete';Then add them to the appropriate roles in your permission configuration:
export const rolePermissions: Record<OrgRole, AppPermission[] | '*'> = { OWNER: '*', ADMIN: [ // ... existing permissions 'document:read', 'document:write', 'document:delete', ], MEMBER: [ // ... existing permissions 'document:read', 'document:write', ], VIEWER: [ // ... existing permissions 'document:read', ],};OWNER automatically gets access to any new permission since it uses the '*' wildcard.