Leave Localhost logoLeave LocalhostDocs
Multi-tenancy

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:

RolePermissionsUse Case
OwnerFull CRUD on organization, members, invitations, workspace records, and billingThe creator of the workspace
AdminRead/Update org, manage members/invites, read billingTrusted team managers
MemberRead org, read membersStandard workspace users
ViewerRead 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 both auth.ts and better-auth/authOptions.ts) and is not wired into any role.
  • workspace.records.* — the optional demo CRUD permissions backed by the workspaceRecord Better 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:

  1. The roles that may take the action
  2. A required Better Auth statement (e.g., organization: ["update"])
  3. Required capabilities (e.g., feature.pro)
  4. 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.

  1. Add the role name to organizationRoleNames in packages/backend/convex/permissions/policy.ts, then add it to every applicable catalog entry's roles list.

  2. Add a generated role binding in auth/organizationAccess.ts; it must call ac.newRole(deriveRoleStatements("support_agent")) rather than declare statements by hand.

  3. Add that binding to the roles object passed to the Better Auth organization plugin in packages/backend/convex/auth.ts:

    roles: {
      owner,
      admin,
      member,
      viewer,
      support_agent: supportAgent,
    }
  4. Add UI labels anywhere roles are displayed:

    • apps/app/src/locales/en.ts
    • apps/app/src/locales/es.ts
    • apps/app/src/locales/fr.ts
  5. 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.

  6. Add or update tests near packages/backend/convex/auth/organizationAccess.test.ts and packages/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.

  1. Add a capability in permissions/capabilities.ts if the feature is packaging-dependent:

    "feature.finance_exports",
  2. Add the capability validator in permissions/validators.ts.

  3. Attach that capability to the relevant paid plan in packages/backend/convex/billing/plans.config.ts.

  4. Add an app permission catalog entry in permissions/policy.ts:

    "finance.export": {
      roles: ["owner", "finance_admin"],
      betterAuth: { billing: ["read"] },
      capabilities: ["feature.finance_exports"],
    },
  5. 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

On this page