Access Control (RBAC)
VexCMS ships role-based access control via defineAccess() — a single permission matrix
covering every collection, global, and custom action, enforced on the server API and
available (advisory) on the client for UI gating.
How it works
Section titled “How it works”defineAccess({ roles, resources, userCollectionSlug, userRolesField, permissions })builds aVexAccessConfig, wired in viadefineConfig({ access }).resourceslists the collections/globals contributing subjects to the matrix; each gets the CRUD actions (create,read,update,delete) automatically, plus draft actions when the resource declaresversions.drafts: true.permissionsisrole → subject → check. A check is{ withIndex?, where?, filter? }(the recommended default — see below), a plain callback, or a staticboolean.- Deny-by-default, with no config knob. An undeclared role/subject/action combination
always resolves to deny — there is no
defaultPermissionModeinput. An allow-everything posture is written explicitly as a role-level wildcard:admin: { "*": true }. - Resolution order per role (first match wins): subject boolean shorthand → explicit
action key → subject-level
"*"→ role-level"*"(undeclared subjects only) → deny. A caller holding multiple roles OR-merges every role’s answer — any role that allows, allows. userRolesFieldnames the field on the user document holding role(s) (stringorstring[]) — callers never pass roles directly; they always ride the resolved user document.
import { defineAccess, defineConfig, defineCollection, relationship, select, text } from "@vexcms/core";
export const posts = defineCollection({ slug: "posts", fields: { title: text({ required: true }), visibility: select({ index: "by_visibility", options: [ { label: "Public", value: "public" }, { label: "Members only", value: "members" }, ], }), authorId: relationship({ collection: { slug: "users" } }), },});
export const access = defineAccess({ roles: ["admin", "editor", "user"], resources: [posts], userCollectionSlug: "users", userRolesField: "roles", permissions: { admin: { "*": true }, editor: { "*": false, posts: true }, user: { "*": false, posts: { read: true } }, },});
export default defineConfig({ collections: [posts], access,});Permission check shapes
Section titled “Permission check shapes”Three forms, in the order you should reach for them.
1. withIndex / where — reach for this first
Section titled “1. withIndex / where — reach for this first”Whenever a rule can be expressed as field comparisons, write it as withIndex and/or
where. The comparison rides directly into the Convex query the Local API
issues instead of pulling every row into JS and discarding what fails a check: on read/
readDrafts, withIndex narrows through a declared index (Convex-specific, query actions
only) — the same { name, range } object find takes, or a function of the caller
returning one — and where is the portable predicate, the same one find and search
take; on create/update/delete — which authorize one document, not a range — only
where applies, compiled to a per-document predicate. Either way the narrowing happens
inside the database query the Local API runs, not as a second pass over already-fetched
documents. filter remains the per-document escape hatch for checks neither can express.
Any combination works; withIndex alone (with a compound index if the rule spans two
fields) and withIndex + filter are the usual shapes. Each property may return true
(no restriction from this half) or false (deny); the static forms always narrow.
Read, pushed through a declared index — ix checks field order against the index at
compile time:
posts: { read: { withIndex: ({ user }) => ({ name: "by_authorId", range: (ix) => ix.eq("authorId", user._id) }), },}Read, pushed through a declared index with no caller-dependent range — the static object form:
posts: { read: { withIndex: { name: "by_status", range: (ix) => ix.eq("status", "published") }, },}Read, where no index range can express the rule — an or across two fields is not one
range, so it is a where. It still compiles into the query’s own .filter(), not a JS
scan-and-discard:
posts: { read: { where: ({ user, q }) => q.or(q.eq("visibility", "public"), q.eq("authorId", user._id)), },}Both together — withIndex narrows to the caller’s rows, then where applies what the
range can’t (a neq is never an index range):
posts: { read: { withIndex: ({ user }) => ({ name: "by_authorId", range: (ix) => ix.eq("authorId", user._id) }), where: ({ q }) => q.neq("status", "archived"), },}Mutations get the same where shape — there’s no query to narrow for a single document, so
only a per-document predicate applies:
posts: { update: { where: ({ user, q }) => q.eq("authorId", user._id), }, delete: { where: ({ user, q }) => q.eq("authorId", user._id), },}where additionally accepts a plain, JSON-serialisable node — the same filter as plain
data, for a rule that needs to be loaded from config rather than written as a callback:
posts: { read: { where: { or: [ { field: "visibility", op: "eq", value: "public" }, { field: "featured", op: "eq", value: true }, ], }, },}The outer object’s filter property is a separate, optional per-document JS callback
that augments withIndex/where for whatever field comparisons can’t express — quoting the
type’s own doc comment: “array membership, string operations, cross-table reads, all
outside FilterBuilder’s surface, so they stay callbacks permanently.” filter never
substitutes for withIndex/where; it only adds to it:
posts: { read: { withIndex: { name: "by_status", range: (ix) => ix.eq("status", "published") }, filter: ({ data }) => data.tags.includes("featured"), },}2. A plain callback — the escape hatch
Section titled “2. A plain callback — the escape hatch”Reach for a bare callback only when the rule genuinely can’t be expressed as field
comparisons — the cases withIndex/where/filter can’t reach, or a rule that needs to
inspect organization or run arbitrary logic. data is the concrete document when the
caller supplies one (edit views), or omitted for a quantified check (nav/list gating), in
which case scope decides how to answer:
posts: { read: true, create: true, update: ({ user, data }) => data?.authorId === user._id, delete: ({ user, data }) => data?.authorId === user._id,}3. Boolean shorthand — the static case
Section titled “3. Boolean shorthand — the static case”For a rule with no per-document or per-caller variation at all — allow everyone, or deny everyone — skip both of the above:
permissions: { admin: { "*": true }, editor: { "*": false, posts: true },}Field-level permissions
Section titled “Field-level permissions”Every check shape above resolves a whole document — a role can read or write posts, full
stop. A filter callback (form 2 above, or the filter beside withIndex/where in form 1) can
answer with a field map instead of a boolean, restricting the check to specific fields
without any new config surface. apps/www uses exactly this to let an anonymous demo caller
see most of a global’s fields but change only one of them:
// apps/www/src/auth/access.ts — the `user` rolesiteSettings: { "*": false, read: () => ({ adminTheme: true, name: true, description: true }), update: () => ({ adminTheme: true }),},Neither map carries a "*" — see “The wildcard is optional” below — so every field the map
doesn’t name is denied for that action. read’s map permits adminTheme, name, and
description; every other field on the global (activeTheme, favicon, the SEO fields) is
unreadable by this role and, per Admin panel gating below, hidden from the edit form
entirely. update’s map permits only adminTheme, so name and description stay
visible (they’re readable) but not editable. Nothing else changed — no fields sibling on
the resource entry, no second wiring point, no new defineAccess option.
System and UI fields are gateable too
Section titled “System and UI fields are gateable too”A field map’s keys aren’t limited to the fields you declared in fields: {}. Three real
system field names are valid map keys on any collection, typed per collection: _creationTime
(always present), updatedAt (present unless the collection sets timestamps: false), and
vex_status (only on a collection with versions.drafts: true). ui()-type fields (a
client-computed value with no stored column, e.g. a wordCount admin-only display) are
gateable the same way, by their own key.
_id is the one exception — it is never a valid map key, and is always readable. Every
document needs a stable identity for links, row keys, and relationship targets regardless of
role; there is no scenario where hiding it is useful.
Every one of these follows the SAME “hidden unless listed” rule a declared data field already
does: a partial map that doesn’t name updatedAt/vex_status/a ui() field hides it, exactly
like an unnamed data field. The wildcard key ("*") covers them too — { "*": true } shows
everything, system and UI fields included.
posts: { "*": false, read: () => ({ title: true, slug: true, updatedAt: true }), // vex_status stays hidden},One carve-out: sorting by _creationTime is always allowed, even for a role whose map
hides its own value — a list view’s default sort has to work regardless of whether the
timestamp itself is ever shown in a column.
Why a map is only ever a callback’s return value
Section titled “Why a map is only ever a callback’s return value”A field map is never written directly on an action — only returned from a callback, whether
that callback is the bare form 2 spelling or filter beside withIndex/where in form 1.
That is what keeps it unambiguous: a document is free to declare fields named withIndex,
where, or filter, because a value assigned directly to an action is always a check
descriptor and a value a callback returns is always a field map. It is also why a map’s
values are plain booleans rather than nested checks — the callback already has
{ user, data, organization }
in scope, so per-field logic is an ordinary boolean expression evaluated once, not a second
permission system:
update: ({ user, data }) => ({ "*": false, adminTheme: true, activeTheme: user._id === data.ownerId && data.status === "draft",}),The wildcard is optional
Section titled “The wildcard is optional”A returned map is checked against the resource’s own fields at compile time, with no
annotation, helper, or satisfies needed at the call site — an unknown key is rejected
whether or not the map carries a wildcard. The wildcard itself, "*", is not required:
"*"present — it decides every field the map doesn’t name explicitly."*"absent — the map is a plain partial record. An omitted field is denied, the same fail-closed default the rest of RBAC uses: a field the map never mentions never gets through silently, including a field added to the resource later.
// Accepted — wildcard present, every other field falls back to it.() => ({ "*": false, adminTheme: true })// Accepted — no wildcard. Only `adminTheme` is named; every other field,// including ones added to the resource later, is denied by omission.() => ({ adminTheme: true })// Rejected — "admnTheme" is not a field on this resource, wildcard or not.() => ({ "*": false, admnTheme: true })Both polarities of the wildcard form read naturally:
() => ({ "*": false, adminTheme: true }) // allow-list: only this field() => ({ "*": true, adminTheme: false }) // deny-list: everything but this fieldPrefer a partial map with no wildcard, or an allow-list wildcard map, over a deny-list one.
The reason is a design one, not a safety one: a field added to the resource later arrives
denied under either of the first two and has to be granted deliberately, but arrives
writable under "*": true.
How hasPermission answers with a map in play
Section titled “How hasPermission answers with a map in play”One function, three cases, and no new call for the common path:
| Call shape | What it means | How a map answers |
| --- | --- | --- |
| changes present | A write — these are the keys actually being set | Denies on the first changed key the map forbids; resending a field unchanged is not a write to it |
| data present, no changes | A read, or an advisory client-side probe | Projects instead of denying — the caller strips denied keys rather than being refused outright |
| Neither | A quantified check (nav/list gating) | Falls back to scope: the default "all" denies, since a map means not every field is permitted; "any" allows |
// WRITE — changes names the keys being written. Any changed key the map denies → false.hasPermission({ access, user, resource: "siteSettings", action: "update", data: storedDoc, changes: { name: "Defaced" } }); // → false (field: "name")hasPermission({ access, user, resource: "siteSettings", action: "update", data: storedDoc, changes: { adminTheme: ["t2"] } }); // → true
// READ — no changes, so the map projects instead of denying. Caller strips.hasPermission({ access, user, resource: "siteSettings", action: "read", data: storedDoc }); // → true
// QUANTIFIED — neither, so scope decides.hasPermission({ access, user, resource: "siteSettings", action: "update" }); // → falsehasPermission({ access, user, resource: "siteSettings", action: "update", scope: "any" }); // → trueA role with no field map anywhere takes byte-identical paths to today — every existing call
site that never passes changes is unaffected. Denial is reported as a VexAccessError
carrying the usual { code: "ACCESS_DENIED", resource, action } plus an optional field —
present only when the denial is field-scoped:
{ "code": "ACCESS_DENIED", "resource": "siteSettings", "action": "update", "field": "name" }A save that changes only adminTheme succeeds even though the form resubmits the whole
document — name, description, and the rest are violations only if their value actually
changed, not merely present in the payload. Comparison is content equality, so a
relationship/select field storing an array isn’t reported as changed just because a
fresh array was constructed for the payload.
create has no stored document to diff against. data is undefined on a create call,
so the “only if changed” leniency above doesn’t apply: any denied key present in the
payload — changed or not, since there is nothing to compare it to — is a violation.
Calling a write action without changes is a developer error, but only when you also ask
to be told. usePermission({ resource: "siteSettings", action: "update", data }) — the
call each admin input’s readOnly prop is built from — passes no changes and no
throwOnDenied, so it lands on the “read” row above and projects, exactly like a real
read. That’s what keeps Save enabled while the map is only resolving individual inputs’
readOnly. Pass throwOnDenied: true on that same shape instead — data supplied,
changes omitted, on create/update/a custom mutation action — and hasPermission
throws a VexAccessError explaining that changes was never supplied, rather than quietly
answering true. Without that guard, a hand-rolled mutation that authorizes with
data: doc and forgets changes would land on the projecting case and be allowed to write
a denied field — the one fail-open in the design, and only reachable by a caller that never
asked for throwOnDenied.
Admin panel gating
Section titled “Admin panel gating”Two hooks from @vexcms/react, both advisory client-side reads of the same map
hasPermission would resolve server-side:
useFieldPermissions({ resource, action, data })resolves a merged{ wildcard, fields }decision for one action — the same shaperesolveFieldPermissionsreturns from@vexcms/core. For thesiteSettings.updateexample above it resolves to{ wildcard: false, fields: { adminTheme: true } }. The edit views fold that into each input’s existingreadOnlyprop withisFieldAllowed(fieldPermissions, fieldKey), so the Admin Theme picker is the only live control. Save stays enabled regardless, because that call passes nochangesand so projects rather than denies, per above.useVisibleFields({ resource, fields, data })resolves the same kind of decision for thereadaction and answers a different question: which of the resource’s configured fields the edit form should render an input for at all. A read-denied field is dropped from the returned list entirely — hidden, not merely disabled, because a caller who can’t read a field’s value has no business seeing that it exists.CollectionEditView,GlobalEditView, andMediaCollectionEditViewall iterate this list instead of the resource’s raw field map.
Hidden and read-only are deliberately different postures for different denials:
| Denial | What the caller sees | Why |
| --- | --- | --- |
| read denied | The field’s input doesn’t render at all | Nothing to show — the value was stripped server-side too |
| update denied, read allowed | The field’s input renders, shows the current value, and is non-interactive | The value is legitimately visible; only changing it is denied |
A read-denied field is also excluded from the form’s defaultValues and its Zod validation
schema, not just its rendered input — so a required field the caller can’t read can never
silently block Save: there’s no hidden, invalid field left for a validator to trip on.
Disclosure controls are never disabled by readOnly. Expanding or collapsing a group,
array, or blocks field is navigation, not editing — a caller holding read but not
update on a field can still open it to see the values underneath. Only the editing
controls inside stay gated: add/remove/reorder buttons and the leaf inputs themselves.
List views filter columns the same way. CollectionListView and
MediaCollectionListView resolve read field permissions with scope: "any" — a column is
shown or hidden for the whole table, so the question is “denied for every document”, not
“for this one” — and drop a column whose field is denied outright, rather than rendering an
empty column for everyone.
Only changed fields are submitted
Section titled “Only changed fields are submitted”CollectionEditView, GlobalEditView, and MediaCollectionEditView submit a diff, not the
whole form: only the top-level fields the user actually edited (tracked via TanStack Form’s
sticky isDirty meta, never the live-tracking isDefaultValue) go into the mutation’s
data. This isn’t a security boundary — the server still re-derives the effective change
set against the stored document — it’s what makes the “only if changed” write semantics
above hold in practice, and what stops two editors on the same document from clobbering
each other’s untouched fields. The companion useLiveFieldMerge hook adopts server-side
changes into any field the current editor hasn’t touched, so an untouched field never goes
stale while someone else’s edits land. A create still submits every field: there’s no prior
document for isDirty to compare against, and a declared field default would otherwise
never be written.
Runtime-only boundary
Section titled “Runtime-only boundary”Like every other RBAC check, field permissions are enforced in the generated Convex functions
and the admin-panel bundle — the same boundary hasPermission() already has. A hand-rolled
Convex function that calls ctx.db directly bypasses this the same way it already bypasses
hasPermission().
Declared view filters are exempt from the field-read check
Section titled “Declared view filters are exempt from the field-read check”A list view’s own where — declared on admin.view/admin.views in config, or saved on an
active, unmodified shared view — still applies server-side even when it names a field the
current caller can’t read (the same treatment vex_status’s draft/publish filtering already
gets). The caller never sees the field’s value (no column, filter chip, or peek row for it);
the view instead shows its “N hidden filter(s)” lock badge. The moment a caller edits a shared
view’s own filter it becomes theirs — fully subject to the read check from then on, like any
other user-supplied condition.
A user-added filter condition (typed into the filter builder, or carried in a URL/local-view link) on a field the caller can’t read is dropped before the query reaches the server, with a small dismissible notice naming the field.
Public API surface
Section titled “Public API surface”Everything above is built from a small set of exports:
@vexcms/core—resolveFieldPermissions(the read-only “which fields” resolver, sharinghasPermission’s role walk so the two can’t drift),isFieldAllowed(read one field out of a resolved decision), andstripDeniedFields(shape a document for a caller before returning it — whatfind/get/search/globals.get/globals.finduse server-side).VexAccessErrorgained an optionalfield, present only on a field-scoped denial.@vexcms/react—useFieldPermissionsanduseVisibleFields, both described above, plususeCanReadField({ resource })— checks readability for a system (_creationTime/updatedAt/vex_status) orui()-type field by key, the same way for any field type.
What’s deferred
Section titled “What’s deferred”find/get/create/update return and argument types still describe the full document —
a caller may receive or be allowed fewer keys than the type promises for their role. Narrowing
those generic type parameters per role is tracked as a backlog item, not attempted here.
Read-only public admin via anonRole
Section titled “Read-only public admin via anonRole”anonRole supplies a fallback role when the caller has no session at all — the exact
mechanism behind a public, read-only admin panel with zero auth friction:
export const access = defineAccess({ roles: ["admin", "guest"], anonRole: "guest", resources: [posts], userCollectionSlug: "users", userRolesField: "roles", permissions: { admin: { "*": true }, guest: { "*": false, posts: { read: true }, adminPanel: { access: true, impersonate: false }, }, },});anonRole only applies when the caller’s resolved roles are genuinely empty — an
authenticated user with a declared role always keeps their own role. Two gates have to
agree to let an anonymous visitor through, if your framework adapter puts a session gate
in front of the route at all (Next.js middleware does): the route itself, and whatever
runs before it reaches the route.
The route checks canAccessAdminPanel BEFORE redirecting on a missing session —
never unconditionally:
import { canAccessAdminPanel } from "@vexcms/core";import { redirect } from "next/navigation";
// `getToken()`/`getUserOrg()` resolved first, as usual. `auth.user` may be `null` here —// an anonymous visitor is a valid caller; `anonRole` supplies the fallback role instead// of this redirecting them out purely for having no session.if (!canAccessAdminPanel({ access: config.access, user: auth.user, organization: auth.organization })) { // Only redirect to sign-in when there was genuinely no token — a signed-in caller // whose own role still fails the check goes to /unauthorized instead. redirect(token ? "/unauthorized" : "/auth/sign-in?redirectTo=/admin");}Middleware (Next.js proxy.ts/middleware.ts), if your app gates /admin at that
layer, needs the SAME check — a session gate there runs before the route ever executes,
so fixing only the route still bounces an anonymous visitor before they reach it:
import { canAccessAdminPanel } from "@vexcms/core";import vexConfig from "~/vex.config";
async function guardAdminRequest(request: NextRequest) { // Pure, synchronous, no I/O — never throws. Skips the session-verification round trip // entirely when `anonRole` already grants `adminPanel.access`. if (canAccessAdminPanel({ access: vexConfig.access, user: null })) { return NextResponse.next(); } // ...your existing session check, unchanged for every app that hasn't opted in.}Both checks are pure config decisions: an app that never sets anonRole (or sets it
without granting adminPanel.access) gets byte-identical behavior to before — neither
gate’s fast path ever fires, and every unauthenticated request still redirects to sign-in
exactly as it always did.
A direct mutation attempt from that same anonymous caller is still rejected server-side —
guest never declares create/update/delete, so those actions fall through the
deny-by-default posture. Don’t combine anonRole with an auth provider’s anonymous-session
plugin (e.g. Better Auth’s anonymous plugin): that plugin creates a real signed-in user, so
the caller’s roles are no longer empty and anonRole never fires — the two are mutually
exclusive.
Enforcing checks
Section titled “Enforcing checks”Server (the enforcement point). hasPermission is the single runtime entry point;
the Local API (vexServerApi) calls it for you on every
collection/global operation:
// convex/vexApi.ts — written onceimport { vexServerApi } from "@vexcms/core/server";import config from "../vex.config.server";import type { DataModel } from "./_generated/dataModel";import { getAuth } from "./vexContext";
export const { get, find, search, create, update, remove, globals } = vexServerApi<DataModel>({ config, getAuth });Per-call overrides ride one grouped access: { action?, bypass? } param on these raw server
functions — never expose it in a client-callable Convex args validator, since an action no
role declares falls through to deny only if the SERVER chose it:
import { query } from "./_generated/server";import { v } from "convex/values";import { find } from "./vexApi";
// A genuinely public read, opted out of RBAC explicitly — costs no session lookup.export const bySlug = query({ args: { slug: v.string() }, handler: (ctx, args) => find({ ctx, collection: "posts", withIndex: { name: "by_slug", range: (q) => q.eq("slug", args.slug) }, access: { bypass: true }, }),});
// Checks a custom action instead of the function's natural verb ("read").export const featured = query({ handler: (ctx) => find({ ctx, collection: "posts", access: { action: "listFeatured" } }),});Client, inside the admin panel. usePermission from @vexcms/react reads access off
useVexConfig() — your project mounts a single VexConfigProvider at the app root, serving
the admin and the public site alike, importing the whole client-safe config directly (a
client-bundle import, not a serialized RSC prop, since callbacks don’t survive RSC
serialization):
"use client";
import { VexConfigProvider } from "@vexcms/react";import config from "../../vex.config";
export function ClientProviders({ children }: { children: React.ReactNode }) { return <VexConfigProvider config={config}>{children}</VexConfigProvider>;}import { usePermission } from "@vexcms/react";import { CRUD_ACTIONS } from "@vexcms/core";
function NewPostButton() { const canCreate = usePermission({ resource: "posts", action: CRUD_ACTIONS.create }); return <button disabled={!canCreate}>New Post</button>;}Client, in your own app UI outside the admin panel. Don’t call
hasPermission from @vexcms/core raw at every call site — build a one-file
wrapper that closes over your access config and auth context once, so call
sites never fetch user/organization themselves. This is the pattern the
VexCMS site itself uses:
// src/auth/hasPermission.ts — written once"use client";
import { hasPermission as hasPermissionCore, type HasPermissionProps } from "@vexcms/core";
import { useAuth } from "~/context/AuthContext";
import { access } from "./access";
type Subjects = NonNullable<typeof access.__subjects>;
/** Client-side permission check for UI affordances (advisory — server guards enforce). */export function hasPermission<TSubject extends keyof Subjects, TData extends object = object>( props: Omit<HasPermissionProps<Subjects, TSubject, TData>, "access" | "organization" | "user">,): boolean { // eslint-disable-next-line react-hooks/rules-of-hooks const { user, organization } = useAuth(); return hasPermissionCore({ access, user, organization, ...props, });}Call sites stay one-liners with full slug-aware inference on resource/action
(that’s what typing the wrapper against the __subjects phantom buys — see the
Omit above):
import { hasPermission } from "~/auth/hasPermission";
hasPermission({ resource: "posts", action: "update", data: post });Either client path is advisory only — hiding or disabling a button is a UX affordance, not enforcement. The server guard is what actually protects the data.
Saved-view permissions
Section titled “Saved-view permissions”VIEW_ACTIONS (readViews, createView, updateView, deleteView) gate shared-view CRUD
on the vex_views table — a different shape from every other action, because a saved view is
one whole collection-level row, never read or written per-field or per-document. A view
action’s check is boolean-only: a plain true/false, or a callback returning
boolean | undefined, receiving only { user, organization? } — no data, since there’s no
document to inspect, and no field-permission map return value either, since partial read/write
of a view row makes no sense.
posts: { readViews: true, createView: ({ user }) => user.roles.includes("editor"), updateView: ({ user }) => user.roles.includes("editor"), deleteView: false,},A field map or a { where, filter, withIndex } constrained-query object returned for a view
action is a type error at the config level. If one reaches runtime anyway (an untyped config,
an as cast), it’s denied with a dev-only console warning — never read as an implicit allow.
Per-view access (a callback that receives the specific stored view a createView/updateView
call targets, for rules narrower than “can this role touch shared views at all”) is tracked as
a backlog item, not built here — the single props-object callback shape is deliberately left
room to add a view property later without another breaking change.
Custom actions and resources
Section titled “Custom actions and resources”Actions beyond CRUD, and subjects that aren’t a collection or global:
export const access = defineAccess({ roles: ["admin", "editor"], resources: [posts], customActions: { posts: { query: ["listFeatured"], mutation: ["publish"] }, }, customResources: { apiKeys: { actions: ["create", "revoke"] }, }, userCollectionSlug: "users", userRolesField: "roles", permissions: { admin: { "*": true }, editor: { "*": false, posts: { read: true, listFeatured: true }, apiKeys: false }, },});Gotchas
Section titled “Gotchas”withIndex/wherebeforefilter,filterbefore a bare callback. A field comparison that a bare callback expresses as({ data }) => data.visibility === "public"reads every matching row into JS just to throw most of them away; the same rule aswithIndex/wherecompiles into the query itself. Reach for a callback only oncewithIndex/where/filtergenuinely can’t express the check.- Deny-by-default has no override knob. There is no
defaultPermissionModeinput field — express an allow-everything posture as a role-level"*": true, which is greppable per-role instead of an invisible global default. anonRoleand an auth provider’s anonymous-session plugin don’t mix. The plugin creates a real user with a non-empty role set, soanonRole’s empty-roles condition never fires.- Client checks are advisory, full stop.
usePermission/hasPermissionon the client gate UI only; the server API (vexServerApi/hasPermissioninside a Convex handler) is the actual enforcement point, and is what you should test against. access.action/access.bypassare server-function parameters, never Convexargs. Accepting them from a client-suppliedargsvalidator lets a caller pick an action no role declared, which falls through to deny only when the choice is trusted — i.e. only when it comes from your own server code.adminPanelis a built-in subject, not a resource you declare —canAccessAdminPanelchecksadminPanel.accessfor you so the subject key and action are never hand-spelled. It’s the gate for reaching/adminat all, separate from the per-collection checks reached once inside.