TinyKit Pro Docs

Authorization System

TinyKit Pro implements a simplified role-based authorization system integrated with Better Auth's built-in access control. The system combines Better Auth ro...

TinyKit Pro implements a simplified role-based authorization system integrated with Better Auth's built-in access control. The system combines Better Auth roles for user-level access with custom organization roles for fine-grained organization permissions, while maintaining a clean and maintainable architecture.

System Architecture

The authorization system features a modular backend architecture with three main components:

  1. Better Auth Integration - Built-in roles & permissions from Better Auth for user access
  2. Custom Organization Roles - Custom role hierarchy for organization-level permissions
  3. Access Level System - Database-driven subscription feature gating (0-3)

Core Simplification

The system has been simplified from granular permission-based access to role-based access:

  • User Level: Admin has all permissions, user has no admin permissions
  • Organization Level: Role hierarchy (owner > admin > member) with specific permissions
  • Focus: Role-based checks instead of detailed permission strings

Core Functions

Backend (Modular Structure in convex/lib/access/)

  • requireAccess(): Main access enforcement - throws on failure (requireAccess.ts)
  • hasAccess(): Boolean permission checking - returns true/false (requireAccess.ts)
  • requireAccessForAction(): Action-specific access control (requireAccessForAction.ts)
  • getUserRolePermissions(): Simplified user role helper - returns ["*"] for admin, [] for user (index.ts)
  • getOrgRolePermissions(): Organization permission arrays (orgPermissions.ts)
  • ac, roles, statements: Better Auth access control configuration (statements.ts)
  • Type definitions: HasAccessOptions, AccessContext (types.ts)

Frontend

  • hasAccess(): Lightweight role checking using user data
  • buildContextFromUserData(): Context building from user data
  • ProtectAccess: Component wrapper for conditional rendering

1. Role-Based Access Control (RBAC)

User Roles (Better Auth Integration)

  • Admin: Complete system access, all administrative functions
  • User: Standard user access, can create and join organizations

The system uses Better Auth's built-in admin() plugin with custom access control configuration:

// In convex/lib/access/statements.ts
import { createAccessControl } from "better-auth/plugins/access";
import {
  defaultStatements,
  adminAc,
  userAc,
} from "better-auth/plugins/admin/access";

export const statements = defaultStatements;
export const ac = createAccessControl(statements);
export const adminRole = adminAc;
export const userRole = userAc;
export const roles = { admin: adminRole, user: userRole } as const;

Organization Roles (Custom Implementation)

  • Owner: Full organization control, billing management, member management, all permissions
  • Admin: Organization settings, member operations (invite/update/remove), content management
  • Member: Basic organization access, read-only access to organization and members

Organization roles are defined with specific permissions in convex/lib/access/orgPermissions.ts:

export const orgRolePermissions = {
  owner: {
    all: true, // All organization permissions
  },
  admin: {
    orgMembers: ["invite", "read", "update", "remove"],
    orgs: ["read", "update"],
    orgInvitations: ["create", "read", "update", "delete"],
  },
  member: {
    orgs: ["read"],
    orgMembers: ["read"],
  },
} as const;

export const ORG_ROLE_HIERARCHY: Record<OrgRole, number> = {
  owner: 3,
  admin: 2,
  member: 1,
};

RBAC Usage

// Import from modular access control system
import { requireAccess, hasAccess } from "../../lib/access";

// Admin-only function
export const deleteUser = mutation({
  args: { userId: v.string() },
  handler: async (ctx, args) => {
    const { userId: adminId } = await requireAccess(ctx, {
      userRole: "admin",
    });

    await ctx.db.delete(args.userId);
    return { success: true };
  },
});

// Organization role check (Better Auth uses string IDs)
export const updateOrgSettings = mutation({
  args: { orgId: v.string(), settings: v.any() },
  handler: async (ctx, args) => {
    const { userId, orgRole } = await requireAccess(ctx, {
      orgId: args.orgId,
      orgRole: ["owner", "admin"], // Owner OR admin
    });

    // Update org via Better Auth component
    return { success: true };
  },
});

// Admin bypass - admin users can access all org functions
export const removeOrgMember = mutation({
  args: { orgId: v.string(), memberId: v.string() },
  handler: async (ctx, args) => {
    // Admin users bypass org role checks automatically
    const { userId } = await requireAccess(ctx, {
      orgId: args.orgId,
      orgRole: "admin", // Org admin OR site admin
    });

    // Remove member logic
    return { success: true };
  },
});

2. Access Level System

Access levels provide subscription-based feature gating using numeric tiers:

  • 0: Free tier (basic features)
  • 1: Basic paid tier (essential premium features)
  • 2: Professional tier (advanced features)
  • 3: Enterprise tier (all features)

Access Level Usage

// Personal access level check
export const advancedAnalytics = query({
  args: {},
  handler: async (ctx) => {
    const { userId } = await requireAccess(ctx, {
      minPersonalAccessLevel: 2, // Professional tier required
    });

    return await getAdvancedAnalytics(ctx, userId);
  },
});

// Organization access level check
export const orgReporting = query({
  args: { orgId: v.string() },
  handler: async (ctx, { orgId }) => {
    const { userId } = await requireAccess(ctx, {
      orgId,
      minOrgAccessLevel: 1, // Basic paid org tier required
    });

    return await getOrgReports(ctx, orgId);
  },
});

// Combined access level requirements
export const enterpriseFeature = mutation({
  args: { orgId: v.string() },
  handler: async (ctx, { orgId }) => {
    const { userId } = await requireAccess(ctx, {
      minPersonalAccessLevel: 2, // User needs Professional
      orgId,
      minOrgAccessLevel: 3, // Organization needs Enterprise
    });

    return await performEnterpriseOperation(ctx, orgId);
  },
});

3. Custom Conditions

For complex logic that doesn't fit standard patterns:

export const complexAccessCheck = mutation({
  args: { orgId: v.string() },
  handler: async (ctx, args) => {
    const { userId } = await requireAccess(ctx, {
      condition: async (ctx) => {
        // Custom logic combining multiple factors
        return (
          ctx.userRole === "admin" ||
          (ctx.orgRole === "owner" && ctx.orgAccessLevel >= 2)
        );
      },
    });

    return await performComplexOperation(ctx, args.orgId);
  },
});

HasAccessOptions Interface

All access control options can be combined for comprehensive authorization:

interface HasAccessOptions {
  // Role checks (supports arrays for multiple allowed roles)
  userRole?: "admin" | "user" | Array<"admin" | "user">;
  orgRole?: "owner" | "admin" | "member" | Array<"owner" | "admin" | "member">;

  // Access level checks (subscription-based)
  minPersonalAccessLevel?: number; // 0=free, 1=basic, 2=pro, 3=enterprise
  minOrgAccessLevel?: number; // Same levels for organizations

  // Context (Better Auth uses string IDs)
  orgId?: string; // Required for org-specific checks

  // Custom logic
  condition?: (ctx: AccessContext) => boolean | Promise<boolean>;
}

Frontend Usage

useAccess Hook

import { useAccess } from "@/hooks/useAccess";

function AdminPanel() {
  const { hasAccess, isLoading } = useAccess();

  if (isLoading) return <LoadingSpinner />;

  // Role-based access
  const isAdmin = hasAccess({
    userRole: "admin"
  });

  // Access level check
  const hasProFeatures = hasAccess({
    minPersonalAccessLevel: 2
  });

  // Organization-specific checks
  const isOrgOwner = hasAccess({
    orgId: org._id,
    orgRole: "owner"
  });

  const hasOrgAdvancedFeatures = hasAccess({
    orgId: org._id,
    minOrgAccessLevel: 2
  });

  return (
    <div>
      {isAdmin && <AdminPanel />}
      {hasProFeatures && <PersonalProFeature />}
      {isOrgOwner && <OrgSettingsButton />}
      {hasOrgAdvancedFeatures && <OrgAdvancedFeature />}
    </div>
  );
}

Component Protection

import { ProtectAccess } from "@/components/ProtectAccess";

// Role-based protection
<ProtectAccess
  userRole="admin"
  fallback={<AccessDenied />}
>
  <AdminPanel />
</ProtectAccess>

// Access level protection
<ProtectAccess
  minPersonalAccessLevel={2}
  fallback={<UpgradePrompt />}
>
  <ProfessionalFeature />
</ProtectAccess>

// Combined protection
<ProtectAccess
  orgId={org._id}
  orgRole="owner"
  minOrgAccessLevel={2}
  fallback={<OrgUpgradePrompt />}
>
  <AdvancedOrgFeature />
</ProtectAccess>

Complex Authorization Examples

Multi-layered Access Control

export const complexOrgOperation = mutation({
  args: { orgId: v.string(), data: v.any() },
  handler: async (ctx, args) => {
    // Multiple requirements in single call
    const { userId, orgRole, personalAccessLevel, orgAccessLevel } =
      await requireAccess(ctx, {
        userRole: ["user"], // Must be authenticated
        orgId: args.orgId, // Organization context
        orgRole: ["owner", "admin"], // Must be owner OR admin
        minPersonalAccessLevel: 1, // Must have paid personal plan
        minOrgAccessLevel: 2, // Must have professional org plan
      });

    // All requirements verified - proceed with operation
    return await performComplexOrgOperation(ctx, args);
  },
});

Frontend Conditional Rendering

const { hasAccess } = useAccess();

// Role-based access
const isAdmin = hasAccess({
  userRole: "admin",
});

const canUpgradeTeam = hasAccess({
  orgId: team._id,
  orgRole: "owner",
  minOrgAccessLevel: 1
});

return (
  <div>
    {isAdmin && <AdminDashboard />}
    {canUpgradeTeam && <UpgradeTeamButton />}
  </div>
);

Access Context

When using requireAccess(), you get back a comprehensive context object:

// Actual AccessContext from convex/lib/access/types.ts
interface AccessContext {
  userId: string; // Better Auth uses string IDs
  user: BetterAuthUser; // Better Auth user from component database
  userRole: "admin" | "user"; // User's system-wide role
  personalAccessLevel: number; // 0=free, 1=basic, 2=pro, 3=enterprise
  orgRole: "owner" | "admin" | "member" | null; // Role within organization (if orgId provided)
  orgAccessLevel: number | null; // Organization's subscription level (if orgId provided)
}

Best Practices

Security Guidelines

  1. Always Check Backend: Never rely solely on frontend permission checks
  2. Principle of Least Privilege: Give users minimum necessary permissions
  3. Layer Your Checks: Combine multiple authorization methods for sensitive operations
  4. Use Role Hierarchy: Leverage role hierarchy (owner > admin > member) for cleaner code

Development Guidelines

  1. Frontend + Backend: Implement permission checks on both layers
  2. Graceful Degradation: Hide UI elements users can't access
  3. Clear Error Messages: Provide helpful feedback when access is denied
  4. Consistent Patterns: Use the same authorization approach across similar features

Performance Considerations

  1. Efficient Queries: Use role checks to avoid unnecessary database calls
  2. Cache Context: Reuse access context within the same request when possible
  3. Selective Subscriptions: Only subscribe to data the user can access

Better Auth Integration

The system integrates with Better Auth's access control plugin:

Server-side (convex/auth.ts):

import { ac, roles } from "./lib/access/statements";

export const createAuth = (ctx, { optionsOnly } = { optionsOnly: false }) => {
  return betterAuth({
    // ... config
    plugins: [
      convex(),
      admin({
        ac,
        roles,
        defaultRole: "user",
      }),
    ],
  });
};

Client-side (src/lib/auth/auth-client.ts):

import { ac, roles } from "@/convex/lib/access/statements";

export const authClient = createAuthClient({
  plugins: [
    convexClient(),
    adminClient({
      ac,
      roles,
    }),
  ],
});

Error Messages

The system provides clear, descriptive error messages:

"Required user role: admin"
"Required organization role: owner"
"Required personal access level: 2"
"Required organization access level: 1"
"Access denied"

Rate Limiting

TinyKit Pro includes built-in rate limiting to protect against abuse:

Backend Rate Limiting

import { requireAccess, requireRateLimit } from "../../lib/access";

export const updateProfile = mutation({
  args: { name: v.string(), bio: v.optional(v.string()) },
  handler: async (ctx, args) => {
    // 1. Authorization check
    const { userId } = await requireAccess(ctx, { userRole: ["user"] });

    // 2. Rate limiting (5 updates/min with burst capacity of 10)
    await requireRateLimit(ctx, "profileUpdates", { key: userId });

    // 3. Business logic
    await ctx.db.patch(userId, { name: args.name, bio: args.bio });
    return { success: true };
  },
});

Pre-configured Limits

  • authAttempts: 10/hour - Sign in, sign up, password reset
  • profileUpdates: 5/min (burst 10) - Profile changes
  • contentCreation: 20/min (burst 30) - Posts, comments
  • adminOperations: 100/hour (burst 150) - Admin actions
  • externalApiCalls: 50/hour (burst 100) - Third-party APIs
  • fileUploads: 10/hour (burst 20) - File uploads
  • searchQueries: 30/min (burst 50) - Search operations

Usage Guidelines

Always use rate limiting for:

  • User-facing mutations (profile updates, content creation)
  • Authentication operations
  • File uploads and expensive operations
  • External API calls
  • Admin operations (prevent accidental abuse)

Don't use for:

  • Internal mutations (already protected by function visibility)
  • Simple queries (unless computationally expensive)

Migration from Granular Permissions

The system was recently simplified from a granular permission-based model to a role-based model:

Before (Granular Permissions):

// Old approach - granular permission strings
hasAccess({ permission: "users:delete" });
hasAccess({ permission: "orgMembers:invite" });

After (Role-Based):

// New approach - simple role checks
hasAccess({ userRole: "admin" }); // Admin has all permissions
hasAccess({ orgId: org._id, orgRole: "admin" }); // Org admin can invite members

Benefits:

  • Reduced complexity and maintenance burden
  • Clearer permission boundaries
  • Better integration with Better Auth
  • Simplified frontend access checks
  • Maintained organization-level granularity where needed

This comprehensive authorization system provides the flexibility to handle everything from simple role checks to complex multi-layered access control, while maintaining clarity and type safety throughout your application.


← Previous: Authentication | Next: Organizations →

On this page

Ship your startup faster. In minutes.

Get TinyKit Pro