Roles and Permissions
Leave Localhost implements Role-Based Access Control (RBAC) backed by Better Auth. Every member of an organization has a specific role that dictates what actions they can perform.
Leave Localhost implements Role-Based Access Control (RBAC) backed by Better Auth. Every member of an organization has a specific role that dictates what actions they can perform.
Roles
Four roles are declared in
packages/backend/convex/permissions/policy.ts:
| Role | Permissions | Use Case |
|---|---|---|
| Owner | Full CRUD on organization, members, invitations, workspace records, and billing | The creator of the workspace |
| Admin | Read/Update org, manage members/invites, read billing | Trusted team managers |
| Member | Read org, read members | Standard workspace users |
| Viewer | Read org, read members (no Pro features) | Read-only guests |
A Note On "Team"
The word "team" appears in three unrelated places. Do not conflate them:
organizationConfig.mode: "team"— the load-bearing multi-workspace tenancy mode (as opposed to"personal"). This controls organization behavior and is not a Better Auth feature switch.- Better Auth sub-teams — a Better Auth organization plugin feature for
nesting teams inside an organization. It is disabled in this starter
(
teams: { enabled: false }in bothauth.tsandbetter-auth/authOptions.ts) and is not wired into any role. workspace.records.*— the optional demo CRUD permissions backed by theworkspaceRecordBetter Auth statement. These authorize the workspace-records demo, not either kind of "team." Remove them only by following the removal guide.
Authorization policy
permissionCatalog is the editable authorization source of truth. Each entry
declares the roles that receive a product permission, its Better Auth statement,
and any capability or resource-policy gate. Better Auth role objects are derived
from the catalog in auth/organizationAccess.ts; do not edit those role grants
directly.
organizationStatements in the policy module is separate on purpose: it defines
the valid Better Auth resource/action vocabulary, not role grants. Add to it
only when a permission needs a genuinely new Better Auth resource or action.
Enforcing Permissions in Convex
Never trust the client to enforce permissions. All Convex functions must
verify access using requireAppPermission() or canAppPermission().
These helpers live in packages/backend/convex/permissions.ts.
Checking a Permission
import { requireAppPermission } from "./permissions";
export const updateSettings = mutation({
args: { ... },
handler: async (ctx, args) => {
// 1. Verify the user is authenticated
// 2. Verify they are a member of the active organization
// 3. Verify their role allows "organization.update"
const actor = await requireAppPermission(ctx, {
permission: "organization.update",
});
// actor.organizationId and actor.appUserId are now safe to use
// ...
}
});App Permission Keys
The strings passed to requireAppPermission (e.g., "organization.update")
are not raw Better Auth statements. They are App Permission Keys defined in
packages/backend/convex/permissions/policy.ts.
An App Permission Key maps a business action to:
- The roles that may take the action
- A required Better Auth statement (e.g.,
organization: ["update"]) - Required capabilities (e.g.,
feature.pro) - A resource policy (e.g.,
organizationMustBeActive)
Resource Policies
Some actions require context-aware checks beyond just "does this role have this
permission?". These are called Resource Policies, defined in
resourcePolicies.ts.
Examples:
cannotRemoveLastOwner: Prevents deleting the only owner of a workspace.cannotModifyOwnerUnlessOwner: Ensures admins can't demote owners.organizationMustBeActive: Prevents actions in suspended/deleted workspaces.
Add A Role
Use this path when you want a new organization role such as support_agent.
-
Add the role name to
organizationRoleNamesinpackages/backend/convex/permissions/policy.ts, then add it to every applicable catalog entry'sroleslist. -
Add a generated role binding in
auth/organizationAccess.ts; it must callac.newRole(deriveRoleStatements("support_agent"))rather than declare statements by hand. -
Add that binding to the
rolesobject passed to the Better Auth organization plugin inpackages/backend/convex/auth.ts:roles: { owner, admin, member, viewer, support_agent: supportAgent, } -
Add UI labels anywhere roles are displayed:
apps/app/src/locales/en.tsapps/app/src/locales/es.tsapps/app/src/locales/fr.ts
-
Update invitation and membership flows if the role should be assignable by users. Start with
packages/backend/convex/members.ts, where inviteable roles are validated. -
Add or update tests near
packages/backend/convex/auth/organizationAccess.test.tsandpackages/backend/convex/permissions/appPermissions.test.ts.
Add A Permission Or Capability
Use an organization permission when access depends on a member's role. Use a billing capability when access depends on what the workspace has paid for. Many product actions need both.
Example: a finance_admin role can manage invoices, but only workspaces with a
paid billing capability can use the feature.
-
Add a capability in
permissions/capabilities.tsif the feature is packaging-dependent:"feature.finance_exports", -
Add the capability validator in
permissions/validators.ts. -
Attach that capability to the relevant paid plan in
packages/backend/convex/billing/plans.config.ts. -
Add an app permission catalog entry in
permissions/policy.ts:"finance.export": { roles: ["owner", "finance_admin"], betterAuth: { billing: ["read"] }, capabilities: ["feature.finance_exports"], }, -
Enforce the rule in Convex:
await requireAppPermission(ctx, { permission: "finance.export" });
RBAC answers "who in this organization may do this?" Capabilities answer "has this workspace bought or been granted this package?" Keep provider-specific product IDs out of authorization checks.
Frontend UI Checks
To conditionally render UI based on permissions, use the useAppPermission hook
in React components:
import { useAppPermission } from "@/lib/auth/use-app-permission";
export function SettingsTab() {
const canUpdate = useAppPermission("organization.update");
if (!canUpdate) {
return <div>You don't have permission to edit settings.</div>;
}
return <SettingsForm />;
}This hook uses the exact same catalog-derived permission logic as the backend, but it only controls visibility — the backend still enforces security.
Next Reads
- Capabilities — how to gate features based on billing plans.
- Security Model — in-depth security architecture.