┌─────────────────────────────────────────────────────────────────┐
│ Your Application │
├─────────────────────────────────────────────────────────────────┤
│ Frontend │ Backend │
│ ─────────────────────────────────│──────────────────────────────│
│ │ │
│ usePermissions() │ authorize() │
│ ├─ can('post.create') │ ├─ Verify authenticated │
│ ├─ can('post.update', post) │ ├─ Verify same org │
│ └─ role, orgId, isAuthenticated │ └─ Verify permission │
│ │ │
│ ▲ │ ▲ │
│ │ uses │ │ uses │
│ │ │ │ │
│ ┌──────┴──────────────────────────────────┴──────────┐ │
│ │ ~/convex/permissions.config.ts │ │
│ │ (shared permission logic) │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Files you'll create:
| File | Purpose |
|---|---|
convex/permissions.config.ts | Permission rules (shared by frontend & backend) |
convex/lib/permissions.ts | Backend authorization helpers |
convex/auth.ts | Permission context query |
composables/usePermissions.ts | Frontend permission composable |
Before starting, ensure you have:
users table in your Convex schemaAdd role and organization support to your schema.
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
// Role validator for type-safe roles
const roleValidator = v.union(
v.literal('owner'),
v.literal('admin'),
v.literal('member'),
v.literal('viewer'),
)
export default defineSchema({
// Organizations table
organizations: defineTable({
name: v.string(),
slug: v.string(),
ownerId: v.string(), // authId of creator
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_slug', ['slug'])
.index('by_owner', ['ownerId']),
// Users table with role
users: defineTable({
authId: v.string(), // From Better Auth
displayName: v.optional(v.string()),
email: v.optional(v.string()),
role: roleValidator, // User's role
organizationId: v.optional(v.id('organizations')),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_auth_id', ['authId'])
.index('by_organization', ['organizationId'])
.index('by_email', ['email']),
// Example: Posts with ownership
posts: defineTable({
title: v.string(),
content: v.string(),
status: v.union(v.literal('draft'), v.literal('published')),
ownerId: v.string(), // authId of creator (for ownership checks)
organizationId: v.id('organizations'),
createdAt: v.number(),
updatedAt: v.number(),
})
.index('by_organization', ['organizationId'])
.index('by_owner', ['ownerId']),
})
Key points:
role fieldownerId (for ownership checks) and organizationId (for org isolation)This file defines WHO can do WHAT. It's shared between frontend and backend.
/**
* Permission Configuration
*
* Defines roles, permissions, and the shared checkPermission() function.
* Used by both frontend (usePermissions) and backend (authorize).
*/
// ============================================
// ROLES
// ============================================
export const ROLES = ['owner', 'admin', 'member', 'viewer'] as const
export type Role = (typeof ROLES)[number]
// ============================================
// PERMISSIONS
// ============================================
// Define WHO can do WHAT.
//
// Simple format: { roles: ['role1', 'role2'] }
// Ownership format: { own: ['role1'], any: ['role2'] }
// - "own" = can do to resources they created
// - "any" = can do to any resource in org
export const permissions = {
// Global permissions (org-level, no resource)
global: {
'org.settings': { roles: ['owner'] },
'org.billing': { roles: ['owner'] },
'org.invite': { roles: ['owner', 'admin'] },
'org.members': { roles: ['owner', 'admin'] },
},
// Post permissions
post: {
create: { roles: ['owner', 'admin', 'member'] },
read: { roles: ['owner', 'admin', 'member', 'viewer'] },
update: { own: ['member'], any: ['owner', 'admin'] },
delete: { own: ['member'], any: ['owner', 'admin'] },
publish: { roles: ['owner', 'admin'] },
},
// Add more resources as needed...
// comment: { ... },
// invoice: { ... },
} as const
// ============================================
// TYPES
// ============================================
// Auto-generate permission type from config
type GlobalPermission = keyof (typeof permissions)['global']
type PostPermission = `post.${keyof (typeof permissions)['post']}`
// Add more as you add resources:
// type CommentPermission = `comment.${keyof typeof permissions['comment']}`
export type Permission = GlobalPermission | PostPermission
export interface PermissionContext {
role: Role
userId: string
}
export interface Resource {
ownerId?: string
}
// ============================================
// CHECK PERMISSION
// ============================================
// Core logic used by both frontend and backend.
type PermissionRule =
| { roles: readonly Role[] }
| { own: readonly Role[]; any: readonly Role[] }
export function checkPermission(
ctx: PermissionContext | null,
permission: Permission,
resource?: Resource,
): boolean {
if (!ctx) return false
// Check global permissions
if (permission in permissions.global) {
const rule = permissions.global[permission as GlobalPermission]
return (rule.roles as readonly string[]).includes(ctx.role)
}
// Parse resource permission: "post.update" → ["post", "update"]
const [resourceType, action] = permission.split('.') as [string, string]
const resourcePerms = permissions[resourceType as keyof typeof permissions]
if (!resourcePerms || resourceType === 'global') return false
const rule = (resourcePerms as Record<string, PermissionRule>)[action]
if (!rule) return false
// Simple permission: { roles: [...] }
if ('roles' in rule) {
return (rule.roles as readonly string[]).includes(ctx.role)
}
// Ownership permission: { own: [...], any: [...] }
// Can do to any resource?
if ('any' in rule && (rule.any as readonly string[]).includes(ctx.role)) {
return true
}
// Can do to own resources?
if ('own' in rule && (rule.own as readonly string[]).includes(ctx.role)) {
if (!resource) return false
return resource.ownerId === ctx.userId
}
return false
}
These utilities enforce permissions in your Convex functions.
/**
* Backend Permission Helpers
*
* Server-side utilities for authorization.
* Use authorize() in every mutation!
*/
import type { QueryCtx, MutationCtx } from '../_generated/server'
import type { Id } from '../_generated/dataModel'
import {
checkPermission,
type Permission,
type PermissionContext,
type Role
} from '../permissions.config'
// ============================================
// TYPES
// ============================================
export interface AuthUser {
_id: Id<'users'>
authId: string
role: Role
organizationId: Id<'organizations'>
displayName?: string
email?: string
}
// ============================================
// GET USER
// ============================================
// Returns current user or null if not authenticated.
export async function getUser(
ctx: QueryCtx | MutationCtx
): Promise<AuthUser | null> {
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 || !user.organizationId) return null
return user as AuthUser
}
// ============================================
// REQUIRE USER
// ============================================
// Returns user or throws if not authenticated.
export async function requireUser(
ctx: QueryCtx | MutationCtx
): Promise<AuthUser> {
const user = await getUser(ctx)
if (!user) throw new Error('Unauthorized')
return user
}
// ============================================
// AUTHORIZE
// ============================================
// The main security gate. Use in EVERY mutation.
//
// Does three things:
// 1. Verifies user is authenticated
// 2. Verifies resource is in user's org (if provided)
// 3. Verifies user has the permission
//
// Usage:
// await authorize(ctx, 'org.invite') // Global
// await authorize(ctx, 'post.update', post) // Resource
export async function authorize(
ctx: QueryCtx | MutationCtx,
permission: Permission,
resource?: { ownerId?: string; organizationId?: Id<'organizations'> },
): Promise<AuthUser> {
const user = await requireUser(ctx)
// Check org isolation
if (resource?.organizationId && resource.organizationId !== user.organizationId) {
throw new Error(`Forbidden: ${permission}`)
}
// Check permission
const permCtx: PermissionContext = {
role: user.role,
userId: user.authId,
}
if (!checkPermission(permCtx, permission, resource)) {
throw new Error(`Forbidden: ${permission}`)
}
return user
}
// ============================================
// REQUIRE SAME ORG
// ============================================
// Type guard for org isolation in queries.
export function requireSameOrg<T extends { organizationId: Id<'organizations'> }>(
user: AuthUser | null,
resource: T | null,
): resource is T {
if (!resource || !user) return false
return resource.organizationId === user.organizationId
}
Add a query to your auth file that returns the permission context for the frontend.
import { query } from './_generated/server'
// ... your existing auth setup ...
// ============================================
// PERMISSION CONTEXT QUERY
// ============================================
// Called by usePermissions() on the frontend.
// Returns role, userId, and orgId for permission checks.
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,
orgId: user.organizationId ?? null,
displayName: user.displayName,
email: user.email,
}
},
})
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 Resource
} from '~/convex/permissions.config'
// Create permission composables for your app
export const { usePermissions, usePermissionGuard } = createPermissions<
Permission
>({
query: api.auth.getPermissionContext,
checkPermission,
})
You can wrap the base composable to add app-specific helpers like role checks or additional context:
import { computed } from 'vue'
import { createPermissions } from '#imports'
import { api } from '~/convex/_generated/api'
import { checkPermission, type Permission, type Resource } from '~/convex/permissions.config'
// Create base composables from module
const { usePermissions: useBasePermissions, usePermissionGuard: basePermissionGuard } =
createPermissions<Permission>({
query: api.auth.getPermissionContext,
checkPermission,
})
// Extended composable with custom helpers
export function usePermissions() {
const base = useBasePermissions()
// Role check helpers
const isOwner = computed(() => base.role.value === 'owner')
const isAdmin = computed(() => base.role.value === 'admin')
const canManageOrg = computed(() =>
base.role.value === 'owner' || base.role.value === 'admin'
)
// App-specific context from the permission query
const orgName = computed(() => (base.user.value as any)?.orgName ?? null)
return {
...base,
// Custom helpers
isOwner,
isAdmin,
canManageOrg,
orgName,
}
}
// Re-export guard with custom login path
export function usePermissionGuard(
permission: Permission,
redirectTo: string = '/',
resource?: Resource,
) {
return basePermissionGuard({
permission,
redirectTo,
resource,
loginPath: '/login', // Your app's login path
})
}
can() returns a ComputedRef<boolean> that Vue auto-unwraps in templates. This means permission checks automatically update when the user's role changes - no manual reactivity handling needed!<script setup lang="ts">
import { usePermissions } from '~/composables/usePermissions'
const { can, role, isAuthenticated } = usePermissions()
const { data: posts } = await useConvexQuery(api.posts.list, {})
</script>
<template>
<!-- Global permission -->
<button v-if="can('org.settings')">Settings</button>
<button v-if="can('org.invite')">Invite Member</button>
<!-- Simple resource permission -->
<button v-if="can('post.create')">New Post</button>
<!-- Ownership permission (pass the resource) -->
<div v-for="post in posts" :key="post._id">
<h3>{{ post.title }}</h3>
<!-- Member can edit own, admin can edit any -->
<button v-if="can('post.update', post)">Edit</button>
<button v-if="can('post.delete', post)">Delete</button>
<!-- Only admin/owner can publish -->
<button v-if="can('post.publish')">Publish</button>
</div>
</template>
<script setup lang="ts">
import { usePermissionGuard } from '~/composables/usePermissions'
// Redirect to /dashboard if user can't access settings
usePermissionGuard({
permission: 'org.settings',
redirectTo: '/dashboard',
})
</script>
<template>
<h1>Organization Settings</h1>
<!-- Only owners see this -->
</template>
import { mutation, query } from './_generated/server'
import { v } from 'convex/values'
import { authorize, getUser, requireSameOrg } from './lib/permissions'
// Query with org isolation
export const list = query({
handler: async (ctx) => {
const user = await getUser(ctx)
if (!user) return []
return ctx.db
.query('posts')
.withIndex('by_organization', q =>
q.eq('organizationId', user.organizationId)
)
.collect()
},
})
// Mutation with permission check
export const create = mutation({
args: { title: v.string(), content: v.string() },
handler: async (ctx, args) => {
// Verify user can create posts
const user = await authorize(ctx, 'post.create')
return ctx.db.insert('posts', {
title: args.title,
content: args.content,
status: 'draft',
ownerId: user.authId,
organizationId: user.organizationId,
createdAt: Date.now(),
updatedAt: Date.now(),
})
},
})
// Mutation with ownership check
export const update = mutation({
args: { id: v.id('posts'), title: v.string() },
handler: async (ctx, args) => {
const post = await ctx.db.get(args.id)
if (!post) throw new Error('Not found')
// Checks org isolation + ownership rules
await authorize(ctx, 'post.update', post)
await ctx.db.patch(args.id, {
title: args.title,
updatedAt: Date.now(),
})
},
})
export const remove = mutation({
args: { id: v.id('posts') },
handler: async (ctx, args) => {
const post = await ctx.db.get(args.id)
if (!post) throw new Error('Not found')
await authorize(ctx, 'post.delete', post)
await ctx.db.delete(args.id)
},
})
Test your implementation: