Features

Policy Helpers

Vextro provides a createPolicyHelpers factory that generates a complete set of RBAC (role-based access control) helper functions for your Convex backend. These helpers resolve user identity from auth tokens, attach roles and permissions from your database, and enforce access requirements in your queries and mutations.

Installation

// convex/lib/policy.ts
import { createPolicyHelpers } from "vextro/convex/policy";

export const {
  getCurrentUser,
  attachRolesAndPermissions,
  requireAuth,
  requireActiveUser,
  hasRole,
  hasAnyRole,
  hasAllRoles,
  requireRole,
  requireAnyRole,
  hasPermission,
  hasAnyPermission,
  hasAllPermissions,
  requirePermission,
  requireAnyPermission,
  AuthorizationError,
} = createPolicyHelpers();

The factory works with the standard Vextro auth schema (users, roles, permissions, userRoles, rolePermissions tables). If your tables use different names, pass a config object.

Expected schema

The policy helpers expect a standard RBAC join-table schema:

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  users: defineTable({
    email: v.string(),
    displayName: v.string(),
    avatarUrl: v.optional(v.string()),
    status: v.union(
      v.literal("active"),
      v.literal("inactive"),
      v.literal("pending")
    ),
    lastLoginAt: v.optional(v.number()),
  }).index("by_email", ["email"]),

  roles: defineTable({
    slug: v.string(),
    name: v.string(),
    description: v.optional(v.string()),
  }).index("by_slug", ["slug"]),

  permissions: defineTable({
    slug: v.string(),
    description: v.optional(v.string()),
  }).index("by_slug", ["slug"]),

  userRoles: defineTable({
    userId: v.id("users"),
    roleId: v.id("roles"),
  }).index("by_userId", ["userId"]),

  rolePermissions: defineTable({
    roleId: v.id("roles"),
    permissionId: v.id("permissions"),
  }).index("by_roleId", ["roleId"]),
});

Config options

If your table or index names differ from the defaults, pass a config object:

const policy = createPolicyHelpers({
  usersTable: "app_users",
  rolesTable: "app_roles",
  permissionsTable: "app_permissions",
  userRolesTable: "user_role_assignments",
  rolePermissionsTable: "role_permission_grants",
  userRolesByUserIdIndex: "by_user",
  rolePermissionsByRoleIdIndex: "by_role",
});
OptionDefaultDescription
usersTable"users"Table name for user records
rolesTable"roles"Table name for role records
permissionsTable"permissions"Table name for permission records
userRolesTable"userRoles"Join table: user ↔ role
rolePermissionsTable"rolePermissions"Join table: role ↔ permission
userTokenIdentifierIndexstring"by_tokenIdentifier"
userRolesByUserIdIndex"by_userId"Index on userRoles for userId lookup
rolePermissionsByRoleIdIndex"by_roleId"Index on rolePermissions for roleId lookup

User resolution

getCurrentUser

Resolves the authenticated user from the Convex auth context. Looks up the user by JWT subject (document ID) first, then falls back to email lookup. Attaches roles and permissions automatically.

import { query } from "./_generated/server";
import { getCurrentUser } from "./lib/policy";

export const myProfile = query({
  args: {},
  returns: v.any(),
  handler: async (ctx) => {
    const user = await getCurrentUser(ctx);
    if (!user) return null;
    // user.roles = [{ slug: "admin", name: "Admin", ... }]
    // user.permissions = ["cms:read", "cms:write", ...]
    return user;
  },
});

Returns null if no valid identity is found. Returns an AuthenticatedUser object with roles and permissions arrays populated from the join tables.

requireAuth

Like getCurrentUser but throws AuthorizationError if no user is found.

const user = await requireAuth(ctx); // throws if not authenticated

requireActiveUser

Like requireAuth but also checks user.status === "active". Throws if the user exists but is inactive or pending.

const user = await requireActiveUser(ctx); // throws if not active

Role checks

hasRole / hasAnyRole / hasAllRoles

Synchronous checks against a resolved user object:

const user = await getCurrentUser(ctx);
if (!user) return;

hasRole(user, "admin");           // true if user has "admin" role
hasAnyRole(user, ["admin", "editor"]); // true if user has either
hasAllRoles(user, ["admin", "editor"]); // true if user has both

requireRole / requireAnyRole

Async guards that resolve the user and throw if the role check fails:

// In a mutation handler
const user = await requireRole(ctx, "admin");
// user is guaranteed active + has "admin" role

const user = await requireAnyRole(ctx, ["admin", "editor"]);
// user has at least one of the listed roles

Permission checks

hasPermission / hasAnyPermission / hasAllPermissions

Synchronous checks against the resolved permissions array:

const user = await getCurrentUser(ctx);
if (!user) return;

hasPermission(user, "cms:write");
hasAnyPermission(user, ["cms:write", "cms:admin"]);
hasAllPermissions(user, ["cms:read", "cms:write"]);

requirePermission / requireAnyPermission

Async guards that resolve the user and throw if the permission check fails:

const user = await requirePermission(ctx, "cms:admin");
const user = await requireAnyPermission(ctx, ["cms:read", "cms:admin"]);

AuthorizationError

A named error class thrown by all require* functions. Useful for distinguishing auth failures from other errors:

import { AuthorizationError } from "vextro/convex/policy";

try {
  await requirePermission(ctx, "cms:admin");
} catch (err) {
  if (err instanceof AuthorizationError) {
    // Handle auth failure specifically
  }
}

Wiring with the admin module

The policy helpers integrate naturally with createVextroAdminModule:

import { createVextroAdminModule } from "vextro/convex/admin";
import {
  getCurrentUser,
  requireAnyPermission,
  requirePermission,
} from "./lib/policy";

const admin = createVextroAdminModule({
  query,
  mutation,
  components,
  getCurrentUser,
  requireAdminRead: async (ctx) => {
    await requireAnyPermission(ctx, ["cms:read", "cms:admin"]);
  },
  requireAdminWrite: async (ctx) => {
    await requirePermission(ctx, "cms:admin");
  },
  getUserRoles: async (ctx) => {
    const user = await getCurrentUser(ctx);
    if (!user) return [];
    return user.roles.map((r) => r.slug);
  },
});

Utility: normalizeEmail

A simple utility for consistent email comparison:

import { normalizeEmail } from "vextro/convex/policy";

normalizeEmail("  John@Example.COM  "); // "john@example.com"

AuthenticatedUser type

The type returned by all user-resolving functions:

import type { AuthenticatedUser } from "vextro/convex/policy";

// {
//   _id: Id<"users">,
//   email: string,
//   displayName: string,
//   avatarUrl?: string,
//   status: "active" | "inactive" | "pending",
//   lastLoginAt?: number,
//   roles: Array<{ _id, slug, name, description? }>,
//   permissions: string[],
//   [key: string]: unknown,  // additional fields from your users table
// }

The [key: string]: unknown index signature means any additional fields on your user record are preserved — you can access custom fields like user.department or user.oktaSub directly.

Previous
Scopes & Multi-Tenancy