Most CRMs ship a permission system that's a UX illusion: the buttons disappear from the screen for users who shouldn't have them, but the underlying API endpoint accepts the request from anyone with a session. That's not security; that's politeness.
Prax CRM's permission model treats the UI gate asconvenience and the server check as the actual lock. This post walks through how it works.
Three layers, one source of truth
Every Prax CRM tenant has three role tiers (admin, employee, customer) and an arbitrary number of designations: "Sales Rep", "Backend Manager", "Accountant", and so on. Roles are coarse; designations are where the granularity lives.
The Access Control matrix maps each designation to a set of per-feature actions:
Designation: "Sales Rep" leads: view ✓ create ✓ edit ✓ delete ✗ sales: view ✓ create ✓ edit ✓ delete ✗ payroll: view ✗ create ✗ edit ✗ delete ✗ users: view ✗ ...
Admin always passes every check; super-admin is platform-wide. Designations are the layer where customers tune access for everyone else.
The UI gate
On the frontend, every privileged button calls a small useGate(feature, action) hook that reads the permission map for the current user. If view is false, the tab disappears from the sidebar. If createis false, the "Add" button hides. If delete is false, the trash icon doesn't render.
That alone is enough for a clean experience — operators don't stare at buttons they can't use. But it's not security. A motivated employee can open DevTools, find the Convex mutation, and call it directly.
The server gate
Every privileged Convex mutation calls requirePermission(ctx, token, feature, action) before doing any work. The helper:
- Verifies the session token is active and unexpired.
- Loads the caller's user row.
- Short-circuits if the caller is admin or super-admin.
- Otherwise resolves the user's designation to its permission template and asserts the requested action is allowed.
- Throws otherwise.
// convex/leads.ts
export const deleteLead = mutation({
args: { token: v.string(), leadId: v.id("leads") },
handler: async (ctx, { token, leadId }) => {
await requirePermission(ctx, token, "assign", "delete");
const callerWs = await getWorkspaceIdFromToken(ctx, token);
const lead = await ctx.db.get(leadId);
if (!lead) throw new Error("Lead not found");
assertSameWorkspace(lead.workspaceId, callerWs);
await ctx.db.delete(leadId);
return { ok: true };
},
});Three checks, every time: permission ✓ workspace ownership ✓ action ✓. Even if a user opens DevTools and crafts a request bypassing the UI, the server says no.
Per-user overrides
Designations are templates, not rigid policies. Sometimes you need to grant one specific person a single extra permission — the team lead who's covering for a manager on holiday, the ops lead who needs payroll access for the quarter-end.
From Admin → Users → ⋯ → Permissions, an admin can override any cell in the matrix for that one user. The override stacks on top of the designation default. The override is itself a row in the audit log, so "who granted what" is always answerable.
Why workspace isolation belongs in the same gate
Permission and tenancy are different concerns, but they fail the same way: a missing check leaks data. We bundle them deliberately. requirePermission handles the per-feature action check; every list query goes through getWorkspaceIdFromToken + the by_workspace index; every update / delete adds assertSameWorkspace on the row.
The result: it's structurally impossible to delete another tenant's lead, even with a leaked admin token. The token identifies your workspace; the row's workspace gets compared; the call refuses.
The audit log
Every privileged action also writes a row to the activity log: who, what, when, on which entity. Read-only and workspace-scoped. Useful for compliance, useful for troubleshooting, useful for catching the rare case where a misconfigured override grants more than expected.
The takeaway
Three rules, in order of importance:
- The server enforces every privileged action. UI gates are convenience, not security.
- Tenancy is part of every check, not a separate concern. Workspace ownership is verified on every mutation.
- Designations are templates; per-user overrides are exceptions, not policy.
Configure your matrix in Admin → Access Control. The buttons disappear and the API refuses — both, every time. Read the permissions docs →