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:
- Better Auth Integration - Built-in roles & permissions from Better Auth for user access
- Custom Organization Roles - Custom role hierarchy for organization-level permissions
- 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 databuildContextFromUserData(): Context building from user dataProtectAccess: 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
- Always Check Backend: Never rely solely on frontend permission checks
- Principle of Least Privilege: Give users minimum necessary permissions
- Layer Your Checks: Combine multiple authorization methods for sensitive operations
- Use Role Hierarchy: Leverage role hierarchy (owner > admin > member) for cleaner code
Development Guidelines
- Frontend + Backend: Implement permission checks on both layers
- Graceful Degradation: Hide UI elements users can't access
- Clear Error Messages: Provide helpful feedback when access is denied
- Consistent Patterns: Use the same authorization approach across similar features
Performance Considerations
- Efficient Queries: Use role checks to avoid unnecessary database calls
- Cache Context: Reuse access context within the same request when possible
- 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 membersBenefits:
- 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.
Authentication System
TinyKit Pro includes a comprehensive authentication system supporting multiple providers and secure password management with context-aware navigation that ad...
Organization Management
TinyKit Pro provides comprehensive organization management capabilities with role-based access control and organization-scoped features.