Skip to main content

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

shared/roles.ts
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

convex/lib/authz.ts
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:

convex/auth.ts
organization({
  requireEmailVerificationOnInvitation: true,
})

Do not make this conditional on a caller-controlled or loosely configured runtime value.

Protected mutation

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