Skip to content

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.

  • defineAccess({ roles, resources, userCollectionSlug, userRolesField, permissions }) builds a VexAccessConfig, wired in via defineConfig({ access }).
  • resources lists the collections/globals contributing subjects to the matrix; each gets the CRUD actions (create, read, update, delete) automatically, plus draft actions when the resource declares versions.drafts: true.
  • permissions is role → subject → check. A check is { withIndex?, where?, filter? } (the recommended default — see below), a plain callback, or a static boolean.
  • Deny-by-default, with no config knob. An undeclared role/subject/action combination always resolves to deny — there is no defaultPermissionMode input. 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.
  • userRolesField names the field on the user document holding role(s) (string or string[]) — callers never pass roles directly; they always ride the resolved user document.
vex.config.ts
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,
});

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"),
},
}

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,
}

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 },
}

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` role
siteSettings: {
"*": 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.

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",
}),

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 field

Prefer 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" }); // → false
hasPermission({ access, user, resource: "siteSettings", action: "update", scope: "any" }); // → true

A 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.

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 shape resolveFieldPermissions returns from @vexcms/core. For the siteSettings.update example above it resolves to { wildcard: false, fields: { adminTheme: true } }. The edit views fold that into each input’s existing readOnly prop with isFieldAllowed(fieldPermissions, fieldKey), so the Admin Theme picker is the only live control. Save stays enabled regardless, because that call passes no changes and so projects rather than denies, per above.
  • useVisibleFields({ resource, fields, data }) resolves the same kind of decision for the read action 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, and MediaCollectionEditView all 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.

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.

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.

Everything above is built from a small set of exports:

  • @vexcms/core — resolveFieldPermissions (the read-only “which fields” resolver, sharing hasPermission’s role walk so the two can’t drift), isFieldAllowed (read one field out of a resolved decision), and stripDeniedFields (shape a document for a caller before returning it — what find/get/search/globals.get/globals.find use server-side). VexAccessError gained an optional field, present only on a field-scoped denial.
  • @vexcms/react — useFieldPermissions and useVisibleFields, both described above, plus useCanReadField({ resource }) — checks readability for a system (_creationTime/ updatedAt/vex_status) or ui()-type field by key, the same way for any field type.

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.

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.

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 once
import { 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:

convex/posts.ts
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):

components/providers/client.tsx
"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.

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.

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 },
},
});
  • withIndex/where before filter, filter before 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 as withIndex/where compiles into the query itself. Reach for a callback only once withIndex/where/filter genuinely can’t express the check.
  • Deny-by-default has no override knob. There is no defaultPermissionMode input field — express an allow-everything posture as a role-level "*": true, which is greppable per-role instead of an invisible global default.
  • anonRole and an auth provider’s anonymous-session plugin don’t mix. The plugin creates a real user with a non-empty role set, so anonRole’s empty-roles condition never fires.
  • Client checks are advisory, full stop. usePermission/hasPermission on the client gate UI only; the server API (vexServerApi / hasPermission inside a Convex handler) is the actual enforcement point, and is what you should test against.
  • access.action/access.bypass are server-function parameters, never Convex args. Accepting them from a client-supplied args validator 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.
  • adminPanel is a built-in subject, not a resource you declare — canAccessAdminPanel checks adminPanel.access for you so the subject key and action are never hand-spelled. It’s the gate for reaching /admin at all, separate from the per-collection checks reached once inside.