convex-roles
v0.1.0
Published
Convex Component for role-based access control, permissions, and authorization
Downloads
84
Maintainers
Readme
convex-roles
A Convex Component for role-based access control (RBAC), permissions, and authorization with full type safety.
Features
- Role hierarchy with inheritance
- Permission checking with wildcard support and autocomplete
- Ownership validation for resources
- Temporal permissions (trials, invitations)
- Audit logging
- React hooks for frontend authorization
- Test utilities for unit testing
- Full type safety with autocomplete for
ctx.db, permissions, and table names
Installation
npm install convex-rolesQuick Start
1. Add the component to your Convex app
// convex/convex.config.ts
import { defineApp } from "convex/server";
import roles from "convex-roles/convex.config";
const app = defineApp();
app.use(roles);
export default app;2. Configure roles with full type safety
// convex/auth.ts
import { Roles } from "convex-roles";
import { components } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
// Define permissions with `as const` for type-safe autocomplete
const permissionsConfig = {
admin: ["*"], // Wildcard: all permissions
moderator: ["users:ban", "content:moderate"],
user: ["content:create", "content:read"],
} as const;
// Create roles with DataModel and Permissions generics
export const roles = new Roles<DataModel, typeof permissionsConfig>(
components.roles,
{
// Define roles with inheritance
roles: {
admin: { inherits: ["moderator"] },
moderator: { inherits: ["user"] },
user: {},
},
// Assign permissions to roles
permissions: permissionsConfig,
// Resolve user from your database
resolver: {
getUser: async (ctx, identity) => {
const user = await ctx.db
.query("users")
.withIndex("by_auth", (q) => q.eq("authId", identity.subject))
.first();
return user ? { id: user._id, roles: user.roles ?? [] } : null;
},
},
// Optional features
features: {
auditLog: true,
},
}
);
export type Permissions = typeof permissionsConfig;3. Use in your functions
// convex/posts.ts
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { roles } from "./auth";
// Require authentication
export const listMine = query({
args: {},
handler: roles.withAuth(async (ctx) => {
return ctx.db
.query("posts")
.withIndex("by_user", (q) => q.eq("userId", ctx.user.id))
.collect();
}),
});
// Require a specific role
export const moderate = mutation({
args: { postId: v.id("posts") },
handler: roles.withRole("moderator", async (ctx, { postId }) => {
return ctx.db.patch(postId, { moderated: true });
}),
});
// Require a specific permission (with autocomplete!)
export const create = mutation({
args: { title: v.string() },
handler: roles.withPermission("content:create", async (ctx, { title }) => {
return ctx.db.insert("posts", { title, userId: ctx.user.id });
}),
});
// Require ownership OR permission
export const update = mutation({
args: { id: v.id("posts"), title: v.string() },
handler: roles.withOwnershipOrPermission({
table: "posts",
ownerField: "userId",
permission: "content:moderate",
}, async (ctx, { id, title }) => {
return ctx.db.patch(id, { title });
}),
});API Reference
Wrapper Methods
| Method | Description |
|--------|-------------|
| withAuth(handler) | Requires authentication |
| withRole(role, handler) | Requires a specific role (with inheritance) |
| withPermission(permission, handler) | Requires a specific permission |
| withOwnership(config, handler) | Requires ownership of a resource |
| withOwnershipOrPermission(config, handler) | Requires ownership OR a permission |
Compose API
For complex authorization logic:
import type { MutationCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
export const deletePost = mutation({
args: { id: v.id("posts") },
handler: roles
.compose<MutationCtx, { id: Id<"posts"> }>()
.withAuth()
.anyOf([
roles.isRole("admin"),
roles.isOwner("posts", "userId"),
])
.handle(async (ctx, { id }) => {
return ctx.db.delete(id);
}),
});Guard Helpers
| Helper | Description |
|--------|-------------|
| isRole(role) | Guard that checks for a role |
| requiresPermission(permission) | Guard that checks for a permission |
| isOwner(table, field) | Guard that checks resource ownership |
Manual Checks
// Get authenticated user
const result = await roles.getUser(ctx);
if (result) {
const { user, identity } = result;
}
// Check permission manually
const canEdit = await roles.hasPermission(ctx, user, "content:edit");
// Check role manually
const isAdmin = roles.hasRole(user, "admin");
// Log to audit
await roles.audit(ctx, {
action: "custom_action",
userId: user.id,
result: canEdit ? "allowed" : "denied",
});Temporal Permissions
Grant time-limited permissions for trials, invitations, etc:
// Grant a 7-day trial
await roles.grantTemporalPermission(ctx, {
userId: "user123",
permission: "premium:access",
validUntil: Date.now() + 7 * 24 * 60 * 60 * 1000,
reason: "Free trial",
});
// Revoke temporal permission
await roles.revokeTemporalPermission(ctx, {
userId: "user123",
permission: "premium:access",
});
// Get active temporal permissions
const perms = await roles.getTemporalPermissions(ctx, "user123");React Hooks
Shared Configuration (Recommended)
To avoid duplicating permissions between backend and frontend, create a shared configuration file:
your-project/
├── shared/
│ └── permissions.ts # Shared configuration
├── convex/
│ └── auth.ts # Backend - imports from shared/
└── src/
└── App.tsx # Frontend - imports from shared/// shared/permissions.ts
export const permissionsConfig = {
admin: ["*"],
moderator: ["users:ban", "content:moderate"],
user: ["content:create", "content:read"],
} as const;
export const rolesConfig = {
admin: { inherits: ["moderator"] },
moderator: { inherits: ["user"] },
user: {},
} as const;
export type Permissions = typeof permissionsConfig;// convex/auth.ts
import { Roles } from "convex-roles";
import { permissionsConfig, rolesConfig } from "../shared/permissions";
export const roles = new Roles<DataModel, typeof permissionsConfig>(
components.roles,
{
roles: rolesConfig,
permissions: permissionsConfig,
// ... resolver config
}
);// src/lib/roles.ts - Create pre-configured hooks once
import { createRolesConfig } from "convex-roles/react";
import { api } from "../convex/_generated/api";
import { permissionsConfig, rolesConfig } from "../../shared/permissions";
export const {
useRoles,
usePermissions,
RequireRole,
RequirePermission,
} = createRolesConfig({
getUserQuery: api.users.getCurrent,
roles: rolesConfig,
permissions: permissionsConfig,
});// src/App.tsx - Use without passing options everywhere
import { useRoles, RequirePermission } from "./lib/roles";
function MyComponent() {
// No options needed - they're pre-configured!
const { user, hasRole, hasPermission, isLoading } = useRoles();
if (isLoading) return <div>Loading...</div>;
return (
<div>
{hasRole("admin") && <AdminPanel />}
{hasPermission("content:create") && <CreateButton />}
{/* Components also work without options */}
<RequirePermission permission="content:moderate">
<ModeratorPanel />
</RequirePermission>
</div>
);
}If you prefer not to use the factory pattern, you can pass options to each hook/component:
import { useRoles, RequirePermission } from "convex-roles/react";
const rolesOptions = {
getUserQuery: api.users.getCurrent,
roles: rolesConfig,
permissions: permissionsConfig,
};
function MyComponent() {
const { hasPermission } = useRoles(rolesOptions);
return (
<RequirePermission permission="content:create" options={rolesOptions}>
<CreateButton />
</RequirePermission>
);
}Note: The shared configuration is safe to expose to the frontend. It only defines the structure of your authorization system (which roles/permissions exist), not the actual user data. The real security checks happen on the backend.
Test Utilities
The package includes test utilities for unit testing:
import {
createMockUser,
createMockTemporalPermission,
createMockAuditEntry,
createMockRolesConfig,
hasRoleWithInheritance,
hasPermissionHelper,
} from "convex-roles/test";
// Create mock data
const user = createMockUser({ roles: ["admin"] });
const config = createMockRolesConfig({
roles: { admin: { inherits: ["user"] }, user: {} },
permissions: { admin: ["*"], user: ["read"] },
});
// Test role inheritance
hasRoleWithInheritance(user, "admin", config.roles); // true
hasRoleWithInheritance(user, "user", config.roles); // true (inherited)
// Test permission checking
hasPermissionHelper(user, "write", config); // true (wildcard)
hasPermissionHelper(user, "read", config); // true (inherited)
// Create mock temporal permission
const perm = createMockTemporalPermission({
userId: user.id,
permission: "premium:access",
validUntil: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
// Create mock audit entry
const entry = createMockAuditEntry({
action: "permission_check",
userId: user.id,
result: "allowed",
});Permission Patterns
Wildcards
const permissions = {
admin: ["*"], // All permissions
moderator: ["content:*"], // All content permissions
} as const;Resource Actions
const permissions = {
user: [
"posts:create",
"posts:read",
"posts:update:own",
"comments:create",
],
} as const;Role Inheritance
Roles can inherit from other roles:
roles: {
admin: { inherits: ["moderator"] }, // admin has moderator + user permissions
moderator: { inherits: ["user"] }, // moderator has user permissions
user: {}, // base role
}Configuration
Full Options
new Roles<DataModel, typeof permissions>(component, {
roles: {
roleName: {
inherits?: string[]; // Roles to inherit from
description?: string; // Optional description
}
},
permissions: {
roleName: string[]; // List of permissions (use `as const`)
},
resolver: {
getUser: (ctx, identity) => Promise<{ id: string; roles: string[] } | null>;
},
features?: {
auditLog?: boolean; // Enable audit logging (default: false)
},
errors?: {
unauthorized?: (reason?: string) => Error; // Custom auth error
forbidden?: (reason?: string) => Error; // Custom forbidden error
notFound?: (resource?: string) => Error; // Custom not found error
},
});Custom Errors
You can customize the errors thrown by authorization checks:
export const roles = new Roles<DataModel, typeof permissionsConfig>(
components.roles,
{
// ... other config
errors: {
unauthorized: (reason) => new ConvexError({
code: "UNAUTHORIZED",
message: reason ?? "Authentication required",
}),
forbidden: (reason) => new ConvexError({
code: "FORBIDDEN",
message: reason ?? "Access denied",
}),
notFound: (resource) => new ConvexError({
code: "NOT_FOUND",
message: `${resource ?? "Resource"} not found`,
}),
},
}
);Component Schema
The component uses two tables:
- temporalPermissions: Time-limited permissions
- auditLog: Permission check history
Package Exports
| Export | Description |
|--------|-------------|
| convex-roles | Main client API (Roles class) |
| convex-roles/react | React hooks and components |
| convex-roles/convex.config | Component configuration |
| convex-roles/test | Test utilities |
License
MIT
