Fourteen SystemsFourteen Systems
← Back to blog
/
architecturerbacsecurity

Why SaaSCoreX Enforces RBAC Server-Side

Most SaaS starters handle permissions like this: check the user's role in the UI, conditionally render a button. If the role is ADMIN, show the delete button. If not, hide it.

This is not security. It's decoration.

Anyone with browser dev tools can call the API directly. If the server doesn't check permissions, the action succeeds. Every UI-only permission check is a vulnerability waiting to be found.

The problem with UI-only protection

Here's what a typical starter gives you:

// Client component — hides the button
{user.role === "ADMIN" && (
  <button onClick={deleteProject}>Delete</button>
)}

The server action behind this button? No role check. No permission gate. The action runs for anyone who calls it.

This pattern ships in most boilerplates because it's easy to build and hard to notice in demos. The gap only becomes visible in production — usually when it's too late.

How SaaSCoreX handles it

SaaSCoreX defines 13 typed actions as a TypeScript union:

export type OrgAction =
  | "org.update"
  | "org.delete"
  | "member.invite"
  | "member.remove"
  | "member.changeRole"
  | "member.list"
  | "project.create"
  | "project.update"
  | "project.delete"
  | "billing.manage"
  | "apiKey.create"
  | "apiKey.revoke"
  | "audit.export";

Each role maps to a set of allowed actions:

const ROLE_PERMISSIONS: Record<MemberRole, Set<OrgAction>> = {
  OWNER: new Set([ /* all 13 actions */ ]),
  ADMIN: new Set([ /* 9 actions */ ]),
  MEMBER: new Set([ /* 4 actions */ ]),
};

And the enforcement function is three lines:

export function requirePermission(
  role: MemberRole, action: OrgAction
): void {
  if (!can(role, action)) {
    throw new PermissionDeniedError(role, action);
  }
}

Every server action and API route calls requirePermission() before performing the operation. Not after. Not optionally. The action physically cannot execute without passing the gate.

Why the type system matters

The OrgAction type is a union, not a string. This means:

  • Typos are compile errors. Writing "member.delte" fails TypeScript, not silently at runtime.
  • New actions require explicit permission mapping. Add a new action to the union, and TypeScript forces you to handle it in the role matrix.
  • Refactoring is safe. Rename an action and every callsite lights up.

This isn't a nice-to-have. In a multi-tenant SaaS with multiple roles, stringly-typed permissions are a class of bug waiting to happen. The type system eliminates them structurally.

The enforcement pattern in practice

Here's what a server action looks like with SaaSCoreX's RBAC:

export async function deleteProject(projectId: string) {
  "use server";
  const session = await requireOrg();
  const membership = await getMembership(
    session.user.id, session.activeOrgId
  );
  requirePermission(membership.role, "project.delete");

  await db.project.delete({
    where: { id: projectId, orgId: session.activeOrgId },
  });
}

Three lines of enforcement before the operation:

  1. requireOrg() — guarantees authentication and active org context
  2. getMembership() — resolves the user's role in the current org
  3. requirePermission() — blocks the action if the role lacks the permission

The UI can still hide buttons for better UX. But the server doesn't trust the UI. It enforces independently.

What this means for your product

When you build on SaaSCoreX, every mutation is gated from day one. You don't need to remember to add permission checks — the pattern is already established. New actions follow the same three-line pattern. The type system catches gaps.

This is one of nine server-enforced subsystems in SaaSCoreX. See the full breakdown on the architecture page.

See the full architecture

Every system discussed here is production code inside SaaSCoreX.