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",
}); | Option | Default | Description |
|---|---|---|
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 |
userTokenIdentifierIndex | string | "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.