This guide walks you through setting up role-based permissions with ownership rules. You'll learn how to control who can create, read, update, and delete resources based on their role.
users table.When you enable permissions: true in your Nuxt config, the module provides one thing: the createPermissions factory function.
import { createPermissions } from '#imports'
export const { usePermissions, usePermissionGuard } = createPermissions({
query: api.auth.getPermissionContext, // Your Convex query
checkPermission: (ctx, permission, resource) => { /* Your logic */ }
})
This gives you:
usePermissions() - A composable that fetches permission context and provides a reactive can() functionusePermissionGuard() - A composable that redirects users without permissionEverything else is yours to define: roles, permission rules, the checkPermission function, and backend authorization. The module just provides the reactive Vue wrapper.
createPermissions at all. You can build your own permission system using plain useConvexQuery to fetch user roles and implement your own logic. This guide shows one approach, but feel free to adapt it or build something completely different.A permission system where:
The permission logic is shared between frontend and backend:
┌───────────────────────────────────────────────────┐
│ permissions.config.ts │
│ (shared permission definitions) │
└───────────────────────┬───────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ Frontend │ │ Backend │
│ usePermissions() │ │ authorize() │
│ can('task.edit') │ │ Permission check │
└───────────────────┘ └───────────────────┘
Files you'll create:
| File | Purpose |
|---|---|
convex/permissions.config.ts | Permission rules (shared) |
convex/lib/permissions.ts | Backend authorization helpers |
convex/auth.ts | Add permission context query |
composables/usePermissions.ts | Frontend permission composable |
Update your users table to include a role field.
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
const roleValidator = v.union(
v.literal('admin'),
v.literal('member'),
v.literal('viewer'),
)
export default defineSchema({
users: defineTable({
authId: v.string(),
displayName: v.optional(v.string()),
email: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
role: roleValidator, // Add this!
createdAt: v.number(),
updatedAt: v.number()
})
.index('by_auth_id', ['authId'])
.index('by_email', ['email']),
// Example: Tasks with ownership
tasks: defineTable({
text: v.string(),
isCompleted: v.boolean(),
ownerId: v.string(), // Track who created it
})
.index('by_owner', ['ownerId']),
});
Update your auth triggers to set a default role for new users:
// In your triggers.user.onCreate:
onCreate: async (ctx, doc) => {
const now = Date.now()
await ctx.db.insert('users', {
authId: doc._id,
displayName: doc.name,
email: doc.email,
avatarUrl: doc.image ?? undefined,
role: 'member', // Default role for new users
createdAt: now,
updatedAt: now
})
}
Create a shared permission config used by both frontend and backend.
// Roles from most to least powerful
export const ROLES = ['admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]
// Permission rules
// Simple: { roles: ['admin'] } - these roles can do it
// Ownership: { own: ['member'], any: ['admin'] } - members own stuff, admins any
export const permissions = {
// Task permissions
'task.create': { roles: ['admin', 'member'] },
'task.read': { roles: ['admin', 'member', 'viewer'] },
'task.update': { own: ['member'], any: ['admin'] },
'task.delete': { own: ['member'], any: ['admin'] },
// Settings (admin only)
'settings.view': { roles: ['admin'] },
} as const
export type Permission = keyof typeof permissions
// The core check function - used by both frontend and backend
export function checkPermission(
userRole: Role | null,
userId: string | null,
permission: Permission,
resourceOwnerId?: string,
): boolean {
if (!userRole) return false
const rule = permissions[permission]
// Simple permission check
if ('roles' in rule) {
return (rule.roles as readonly string[]).includes(userRole)
}
// Ownership permission check
if ('any' in rule && (rule.any as readonly string[]).includes(userRole)) {
return true
}
if ('own' in rule && (rule.own as readonly string[]).includes(userRole)) {
return resourceOwnerId === userId
}
return false
}
How permissions work:
| Permission Rule | Meaning |
|---|---|
{ roles: ['admin', 'member'] } | Admin or member can do it |
{ own: ['member'], any: ['admin'] } | Members can do it to their own resources, admins to any |
Create utilities to enforce permissions in your Convex functions.
import type { QueryCtx, MutationCtx } from '../_generated/server'
import { checkPermission, type Permission, type Role } from '../permissions.config'
// Get the current user with their role
export async function getUser(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity()
if (!identity) return null
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', q => q.eq('authId', identity.subject))
.first()
return user
}
// Check permission and throw if denied
export async function authorize(
ctx: QueryCtx | MutationCtx,
permission: Permission,
resourceOwnerId?: string,
) {
const user = await getUser(ctx)
if (!user) {
throw new Error('Not authenticated')
}
const allowed = checkPermission(
user.role as Role,
user.authId,
permission,
resourceOwnerId
)
if (!allowed) {
throw new Error(`Permission denied: ${permission}`)
}
return user
}
Add a query to your convex/auth.ts that returns the permission context for the frontend.
import { query } from './_generated/server'
// ... your existing auth setup ...
// Permission context query - called by usePermissions() on frontend
export const getPermissionContext = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) return null
const user = await ctx.db
.query('users')
.withIndex('by_auth_id', q => q.eq('authId', identity.subject))
.first()
if (!user) return null
return {
role: user.role,
userId: user.authId,
}
},
})
Enable the permission composables in your module config:
export default defineNuxtConfig({
modules: ['better-convex-nuxt'],
convex: {
url: process.env.CONVEX_URL,
permissions: true, // Enable createPermissions
},
})
Use createPermissions to create your app's permission composable:
import { createPermissions } from '#imports'
import { api } from '~~/convex/_generated/api'
import { checkPermission, type Permission, type Role } from '~~/convex/permissions.config'
export const { usePermissions, usePermissionGuard } = createPermissions<Permission>({
query: api.auth.getPermissionContext,
checkPermission: (ctx, permission, resource) => {
if (!ctx) return false
return checkPermission(
ctx.role as Role,
ctx.userId,
permission,
resource?.ownerId
)
},
})
Update your Convex functions to use the authorize helper.
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'
import { authorize, getUser } from './lib/permissions'
export const get = query({
handler: async (ctx) => {
// Anyone authenticated can read
const user = await getUser(ctx)
if (!user) return []
const tasks = await ctx.db.query('tasks').collect()
// Get creator names for display
return Promise.all(
tasks.map(async (task) => {
const creator = await ctx.db
.query('users')
.withIndex('by_auth_id', (q) => q.eq('authId', task.ownerId))
.first()
return {
...task,
creatorName: creator?.displayName ?? 'Unknown',
}
})
)
},
})
export const create = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
// Check permission to create
const user = await authorize(ctx, 'task.create')
return ctx.db.insert('tasks', {
text: args.text,
isCompleted: false,
ownerId: user.authId, // Track owner for permission checks
})
},
})
export const update = mutation({
args: { id: v.id('tasks'), text: v.string() },
handler: async (ctx, args) => {
const task = await ctx.db.get(args.id)
if (!task) throw new Error('Task not found')
// Check permission - passes ownerId for ownership check
await authorize(ctx, 'task.update', task.ownerId)
await ctx.db.patch(args.id, { text: args.text })
},
})
export const remove = mutation({
args: { id: v.id('tasks') },
handler: async (ctx, args) => {
const task = await ctx.db.get(args.id)
if (!task) throw new Error('Task not found')
await authorize(ctx, 'task.delete', task.ownerId)
await ctx.db.delete(args.id)
},
})
can()Use usePermissions() to show/hide UI elements based on permissions:
<script setup lang="ts">
import { api } from '~~/convex/_generated/api'
import { usePermissions } from '~/composables/usePermissions'
const { can, role } = usePermissions()
const { data: tasks } = await useConvexQuery(api.tasks.get, {}, { server: true })
const { mutate: createTask } = useConvexMutation(api.tasks.create)
const { mutate: deleteTask } = useConvexMutation(api.tasks.remove)
const newTaskText = ref('')
// can() returns a ComputedRef - Vue auto-unwraps in templates
const canCreateTask = can('task.create')
</script>
<template>
<div>
<p>Your role: <strong>{{ role }}</strong></p>
<!-- Only show create form if user can create -->
<form v-if="canCreateTask" @submit.prevent="createTask({ text: newTaskText })">
<input v-model="newTaskText" placeholder="New task..." />
<button type="submit">Add</button>
</form>
<ul>
<li v-for="task in tasks" :key="task._id">
{{ task.text }}
<small>(by {{ task.creatorName }})</small>
<!-- Pass the resource for ownership checks -->
<button v-if="can('task.update', task).value">Edit</button>
<button v-if="can('task.delete', task).value" @click="deleteTask({ id: task._id })">
Delete
</button>
</li>
</ul>
</div>
</template>
can() returns a ComputedRef<boolean>. Vue auto-unwraps it in templates (v-if="can('task.create')"), but in script you need .value when passing the resource (can('task.delete', task).value).usePermissionGuard()Protect entire pages from unauthorized access:
<script setup lang="ts">
import { usePermissionGuard } from '~/composables/usePermissions'
// Redirects to /tasks if user can't view settings
usePermissionGuard({ permission: 'settings.view', redirectTo: '/tasks' })
</script>
<template>
<h1>Settings</h1>
<!-- Only admins see this page -->
</template>
switchRole mutation below is for development and testing only. It allows users to change their own role, which is a security risk in production. The mutation includes a production safeguard that throws an error if executed in production, but you should remove this entire mutation before deploying to production.For development, add a mutation to switch roles:
import { ROLES } from './permissions.config'
// DEV ONLY: Switch current user's role for testing
// ⚠️ REMOVE THIS MUTATION IN PRODUCTION - It's a security risk!
export const switchRole = mutation({
args: { role: v.union(...ROLES.map(r => v.literal(r))) },
handler: async (ctx, args) => {
// Prevent execution in production
if (process.env.NODE_ENV === 'production') {
throw new Error('switchRole is disabled in production')
}
const user = await getUser(ctx)
if (!user) throw new Error('Not authenticated')
await ctx.db.patch(user._id, { role: args.role, updatedAt: Date.now() })
},
})
Then add role switcher buttons to your page:
<script setup lang="ts">
import { ROLES } from '~~/convex/permissions.config'
const { mutate: switchRole } = useConvexMutation(api.tasks.switchRole)
const { role } = usePermissions()
</script>
<template>
<div>
Role:
<button
v-for="r in ROLES"
:key="r"
:disabled="role === r"
@click="switchRole({ role: r })"
>
{{ r }}
</button>
</div>
</template>
Test your implementation:
Here's the complete project structure after adding permissions.
// Roles from most to least powerful
export const ROLES = ['admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]
// Permission rules
export const permissions = {
'task.create': { roles: ['admin', 'member'] },
'task.read': { roles: ['admin', 'member', 'viewer'] },
'task.update': { own: ['member'], any: ['admin'] },
'task.delete': { own: ['member'], any: ['admin'] },
'settings.view': { roles: ['admin'] },
} as const
export type Permission = keyof typeof permissions
export function checkPermission(
userRole: Role | null,
userId: string | null,
permission: Permission,
resourceOwnerId?: string,
): boolean {
if (!userRole) return false
const rule = permissions[permission]
if ('roles' in rule) {
return (rule.roles as readonly string[]).includes(userRole)
}
if ('any' in rule && (rule.any as readonly string[]).includes(userRole)) {
return true
}
if ('own' in rule && (rule.own as readonly string[]).includes(userRole)) {
return resourceOwnerId === userId
}
return false
}