Role-based access control as a Convex component — typed, sandboxed, runtime-editable roles and grants keyed by opaque refs.
// Define a role, assign it, enforce it — all from host functions.
await permissions.defineRole(ctx, { name: "editor", grants: ["doc.read", "doc.edit"] });
await permissions.assign(ctx, { subjectRef: userId, role: "editor" });
await permissions.require(ctx, { subjectRef: userId, action: "doc.edit" }); // throws on deny- Stored, runtime-editable RBAC — roles, grants, and assignments live in sandboxed tables; change them with a mutation, no redeploy.
- Typed end to end —
Permissions<TRole, TAction>is generic over your role and action unions. - Wildcard grants —
"doc.*"grants everydoc.action;"*"grants everything. - Scoped / multi-tenant — assign within an opaque
scopeRef; unscoped assignments apply globally. - Agnostic by construction — opaque
subjectRef/scopeRef, no auth library, no domain model, no vendor. requirethrows —ConvexError<PermissionDenied>the host maps to a 403.- Default-deny — unknown subjects and unmatched actions are denied.
- Runs anywhere — identical on Convex Cloud and self-hosted
convex-backend.
npm install @vllnt/convex-permissionsPeer dependency: convex@^1.36.1.
// convex/convex.config.ts
import { defineApp } from "convex/server";
import permissions from "@vllnt/convex-permissions/convex.config";
const app = defineApp();
app.use(permissions);
export default app;// convex/permissions.ts — instantiate the typed client, then define / assign / check.
import { components } from "./_generated/api";
import { Permissions } from "@vllnt/convex-permissions";
type Role = "admin" | "editor" | "viewer";
type Action = "doc.read" | "doc.edit" | "doc.delete";
export const permissions = new Permissions<Role, Action>(components.permissions);
// from any host mutation/query (gate management behind your own admin auth):
await permissions.defineRole(ctx, { name: "editor", grants: ["doc.read", "doc.edit"] });
await permissions.assign(ctx, { subjectRef: userId, role: "editor", scopeRef: orgId });
const allowed = await permissions.check(ctx, { subjectRef: userId, action: "doc.edit" });
await permissions.require(ctx, { subjectRef: userId, action: "doc.edit" }); // throws on deny| Method | Kind | Result |
|---|---|---|
defineRole(ctx, { name, grants, description? }) |
mutation | Upsert a role (runtime-editable) |
removeRole(ctx, name) |
mutation | Delete a role by name |
assign(ctx, { subjectRef, role, scopeRef? }) |
mutation | Grant a role to a subject |
revoke(ctx, { subjectRef, role, scopeRef? }) |
mutation | Remove a role from a subject |
check(ctx, { subjectRef, action, scopeRef? }) |
query | Boolean permission check (default-deny) |
require(ctx, { subjectRef, action, scopeRef? }) |
query | Enforce access — throws on deny |
rolesFor(ctx, { subjectRef, scopeRef? }) |
query | List role names for a subject |
permissionsFor(ctx, { subjectRef, scopeRef? }) |
query | List distinct grants for a subject |
listRoles(ctx) |
query | All role definitions |
Full reference: docs/API.md.
- Host owns auth — it authenticates the caller, resolves identity to an opaque
subjectRef, and gates the management methods behind its own admin authorization. - Opaque refs only —
subjectRef,scopeRef, and action keys are arbitrary strings; tables are sandboxed (reached only via the client). - Default-deny — no matching grant ⇒ denied; boundary validation rejects refs not matching
^[A-Za-z0-9_.:-]{1,128}$.
See docs/API.md.
pnpm test # single run
pnpm test:coverage # enforced 100% on covered filesTests run against the real component runtime via convex-test (@edge-runtime/vm), not mocks.
See CONTRIBUTING.md.
Built by bntvllnt · bntvllnt.com · X @bntvllnt
Part of the @vllnt Convex component fleet — vllnt.com
If this is useful, sponsor the work.
MIT — see LICENSE.