Organization Permissions
Build application-owned organization access checks on canonical membership data.
Result
Convex functions enforce organization membership and role policy. The frontend reads a capability context only to present allowed controls.
Shared role vocabulary
export const roles = ['owner', 'admin', 'member', 'viewer'] as const
export type Role = (typeof roles)[number]
export function canCreateProject(role: Role) {
return role === 'owner' || role === 'admin' || role === 'member'
}Pure shared policy can improve consistency. Backend data determines the actual role.
Backend helper
export async function requireOrganizationMember(ctx: Ctx, organizationId: string) {
const identity = await requireIdentity(ctx)
const membership = await ctx.db
.query('memberships')
.withIndex('by_organization_user', (q) =>
q.eq('organizationId', organizationId).eq('userId', identity.subject),
)
.unique()
if (!membership) throw new ConvexError({ code: 'ORGANIZATION_NOT_FOUND' })
return membership
}If Better Auth Organization owns memberships, query or call that canonical component instead of mirroring membership rows into an application table.
For email invitations, require verified ownership before acceptance:
organization({
requireEmailVerificationOnInvitation: true,
})Do not make this conditional on a caller-controlled or loosely configured runtime value.
Protected mutation
const membership = await requireOrganizationMember(ctx, args.organizationId)
if (!canCreateProject(membership.role)) {
throw new ConvexError({ code: 'FORBIDDEN' })
}UI capability query
Return the role and named capabilities from a protected query. The UI may hide “Create project,” but the mutation repeats the same check.
Verify the matrix
Test anonymous, non-member, viewer, member, admin, and owner callers. Include a valid membership from a different organization to prevent cross-organization key mistakes.