Convex
The permdock/convex provider builds a PermDock subject from ctx.auth inside Convex queries, mutations and actions, and ships the snapshot to the client through a Convex query.
Status: planned Phase: 4
permdock/convex is a provider for Convex functions. Inside a query, mutation or action it turns ctx.auth.getUserIdentity() (plus any roles stored in Convex tables) into a PermDock subject and a request-scoped PermDock, so function bodies call assert, can and filter with typed permissions. A companion query returns the snapshot so the Convex React client can drive PermDockProvider.
Purpose
Convex has authentication (ctx.auth) and leaves authorization to code inside each function. Permission logic therefore lives as if statements per function, unshared with the React client, and cannot be explained or audited. PermDock fits Convex's per-invocation model well: every function invocation is a request, so an immutable request-scoped PermDock per invocation is natural, and Convex's reactive queries deliver snapshot updates to the client without polling.
API
// convex/permdock.ts
import { createPermDock } from 'permdock/convex'
import { policy } from './policy'
export const { withPermDock, snapshotQuery } = createPermDock(policy, {
subject: async (ctx) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) return null
const user = await ctx.db.query('users').withIndex('by_token', (q) => q.eq('tokenIdentifier', identity.tokenIdentifier)).unique()
return user && { id: user._id, orgId: user.orgId, roles: user.roles }
},
})
// convex/posts.ts
export const remove = mutation({
args: { id: v.id('posts') },
handler: withPermDock(async (ctx, { id }) => {
const post = await ctx.db.get(id)
ctx.permdock.assert(permissions.post.delete, post)
await ctx.db.delete(id)
}),
})
export const list = query({
args: {},
handler: withPermDock(async (ctx) => ctx.permdock.filter(permissions.post.read, await ctx.db.query('posts').collect())),
})
// convex/permissions.ts
export const snapshot = snapshotQuery() // client: useQuery(api.permissions.snapshot) → PermDockProvider snapshotcreatePermDock(policy, options)returnswithPermDock, a handler wrapper that resolves the subject fromctx, builds the request-scoped instance and attaches it asctx.permdock; andsnapshotQuery, a ready-made Convex query that returnspermdock.snapshot()for the caller (optionally scoped:snapshotQuery({ include: [permissions.post] })).subjectreceives the Convex context so it can read identity and role tables; it may be sync when roles live in the identity claims.- Works for
query,mutation,actionandinternal*variants; in actions without database access the subject resolver must rely on identity claims.
Request lifecycle
- A Convex function is invoked with an authenticated (or anonymous) caller.
withPermDockcallssubject(ctx); identity comes fromctx.auth, roles from claims or a table lookup.createPermDock(policy, subject)producesctx.permdock, frozen for this invocation.- The handler calls
assertbefore writes andfilter/canfor reads; conditions evaluate against documents already loaded fromctx.db. on('decision')events are collected per invocation and, when configured, written to an audit table throughctx.db.insertat the end of a mutation (queries cannot write and log throughconsoleinstead).- Client:
useQuery(api.permissions.snapshot)re-runs reactively when the user's roles document changes, soPermDockProviderreceives the new snapshot withoutinvalidate().
What it validates
- Identity comes only from
ctx.auth; function arguments never influence the subject. - Arguments are validated by Convex validators (
v.*) before the handler runs; documents loaded fromctx.dbare trusted server data and are not re-validated (validate: 'boundary'treats them as trusted). - Roles read from a table must match role names in
definePolicy; unknown names are dropped with a warning. filteron collected documents is in-process; Convex has nowherecompiler target, so large tables should be indexed by the condition fields (for exampleby_author) and queried withwithIndexbeforefilteris applied as the final gate.
How denials surface
assertthrowsPermDockDeniedErrororPermDockApprovalRequiredError; the wrapper converts them into a ConvexConvexErrorwhosedatais the RFC 9457 Problem Details object (type,title,permission,denials,alternatives), so the client receives structured, serialisable denial data.filterandcannever throw; a query returns the permitted subset.- Anonymous callers yield the anonymous subject; only anonymous grants apply.
- The snapshot query itself never fails on denial; it returns whatever grants the subject holds, including none.
Example app
apps/examples/convex: a Vite React app with Convex Auth, a users table with roles, withPermDock on posts functions, snapshotQuery feeding PermDockProvider, Protected in the UI, and a test that a role change in the users document flips usePermission without a reload.
Related standards
- Subject: principal and roles.
- Snapshots: what the snapshot query returns.
- Errors:
PermDockDeniedErrormapped toConvexError. - Research: landscape: where Convex fits among providers.
Open questions
withPermDockandsnapshotQueryare proposed names; the plan only states "ctx.auth identity to subject inside Convex functions; snapshot to client".- Whether to also offer a
customQuery/customMutationbuilder (convex-helpers style) soctx.permdockis available without wrapping each handler. - Closure grants: the client snapshot marks them
server-only; whether the decision endpoint should be a Convex query or reuse the HTTP AuthZEN handler through Convex HTTP actions. - Audit persistence from queries (read-only) versus mutations; a scheduled action may be needed to flush query-side decisions.
Clerk
The permdock/clerk provider maps Clerk session claims, organization roles and permissions to a PermDock subject so PermDock conditions, snapshots and adapters run on top of Clerk authentication.
JWT
permdock/jwt verifies bearer JWTs against a JWKS or secret with jose as an optional peer and returns a PermDock subject: principal from sub, delegation from scope and authorization_details, actor from act, sender binding from cnf. Verification failure yields the anonymous subject, never an exception.