String-based permission checks scattered across your React codebase are a maintenance nightmare. I know because I shipped CitizenApp with that anti-pattern, and it nearly bit me when we added our fifth AI feature.
The problem? Permissions were hardcoded in components. When the marketing team wanted to trial a feature with select customers, I had to grep through half the codebase, find every if (user.role === 'admin')
check, and create some Frankenstein conditional. Worse, there was no single source of truth for what "feature_x_access" actually meant across our tenant hierarchy.
This post shows you how to build a type-safe, composable RBAC layer that lives outside your components. Your UI asks "can I do X?" and the permission engine answers. Clean separation. Testable. Scales.
The Core Philosophy: Permissions Are Data, Not Logic #
Don't embed permissions in your component tree. Treat permissions as configuration that your components consume. This single shift unlocks everything.
Here's what bad looks like:
// ❌ Don't do this
export function AIFeatureCard() {
const { user } = useAuth();
if (user.role !== 'admin' && user.role !== 'premium_subscriber') {
return null;
}
return <div>AI Feature</div>;
}
Problems:
- Role names are magic strings
- Permission logic is fragmented across 20 components
- Adding a new role? Find and update every check
- No way to test permissions without rendering components
- No audit trail of what permission was checked where
Here's what good looks like:
// ✅ Do this
const canAccessAIFeature = await checkPermission('features:ai:access', {
userId,
tenantId,
});
if (canAccessAIFeature) {
return <AIFeatureCard />;
}
Now permissions are data. You can log them, cache them, test them, audit them.
Type-Safe Permission Definitions #
Start with TypeScript. Define every permission your app has as a const object:
// permissions.ts
export const PERMISSIONS = {
// Organization management
'org:create': 'Create organization',
'org:update': 'Update organization settings',
'org:delete': 'Delete organization',
'org:invite_members': 'Invite team members',
// AI features (your feature gates)
'features:ai:access': 'Access any AI feature',
'features:ai:documents': 'Use document analysis',
'features:ai:workflows': 'Create automation workflows',
'features:ai:exports': 'Export AI-generated content',
// Admin
'admin:billing': 'Manage billing',
'admin:audit_logs': 'View audit logs',
} as const;
// Extract type: 'org:create' | 'org:update' | ...
export type PermissionKey = keyof typeof PERMISSIONS;
This gives you autocomplete and catches typos at compile time. No more permission strings as magic text.
Building the Permission Engine #
Your permission engine lives on the backend and is queried from React. Here's the FastAPI service:
from enum import Enum
from typing import Set
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
class RoleType(str, Enum):
OWNER = "owner"
ADMIN = "admin"
MEMBER = "member"
GUEST = "guest"
ROLE_PERMISSIONS: dict[RoleType, Set[str]] = {
RoleType.OWNER: {
"org:create", "org:update", "org:delete", "org:invite_members",
"features:ai:access", "features:ai:documents", "features:ai:workflows",
"features:ai:exports", "admin:billing", "admin:audit_logs",
},
RoleType.ADMIN: {
"org:update", "org:invite_members",
"features:ai:access", "features:ai:documents", "features:ai:workflows",
"features:ai:exports", "admin:audit_logs",
},
RoleType.MEMBER: {
"features:ai:access", "features:ai:documents", "features:ai:workflows",
},
RoleType.GUEST: {
"features:ai:documents", # Read-only
},
}
class PermissionResolver:
def __init__(self, db: Session):
self.db = db
async def get_user_permissions(
self, user_id: str, tenant_id: str
) -> Set[str]:
"""Resolve all permissions for a user in a tenant."""
membership = self.db.query(TenantMembership).filter(
TenantMembership.user_id == user_id,
TenantMembership.tenant_id == tenant_id,
).first()
if not membership:
return set()
permissions = ROLE_PERMISSIONS.get(membership.role, set()).copy()
custom = self.db.query(UserPermission).filter(
UserPermission.user_id == user_id,
UserPermission.tenant_id == tenant_id,
).all()
for perm in custom:
permissions.add(perm.permission_key)
return permissions
async def can_perform(
self, user_id: str, tenant_id: str, permission: str
) -> bool:
"""Check if user can perform an action."""
permissions = await self.get_user_permissions(user_id, tenant_id)
return permission in permissions
@app.post("/api/permissions/check")
async def check_permission(
request: CheckPermissionRequest, # {permission, tenant_id}
user_id: str = Depends(get_current_user_id),
db: Session = Depends(get_db),
) -> dict[str, bool]:
resolver = PermissionResolver(db)
allowed = await resolver.can_perform(
user_id, request.tenant_id, request.permission
)
return {"allowed": allowed}
Notice: No logic in components. All rules live in ROLE_PERMISSIONS
and the database. When you add a feature, you update ROLE_PERMISSIONS
once. Done.
React Hook: usePermission #
Now the React side. Create a hook that queries your permission endpoint:
// usePermission.ts
import { useAuth } from './useAuth';
import { PermissionKey, PERMISSIONS } from './permissions';
interface UsePermissionOptions {
cacheSeconds?: number;
}
export function usePermission(
permissionKey: PermissionKey,
options: UsePermissionOptions = {}
) {
const { user, tenantId } = useAuth();
const [allowed, setAllowed] = React.useState<boolean | null>(null);
const [, set] = React.useState(true);
const [error, setError] = React.useState<Error | null>(null);
React.useEffect(() => {
if (!user || !tenantId) {
set(false);
setAllowed(false);
return;
}
const checkPerm = async () => {
try {
const response = await fetch('/api/permissions/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
permission: permissionKey,
tenant_id: tenantId,
}),
});
if (!response.ok) throw new Error('Permission check failed');
const data = await response.json();
setAllowed(data.allowed);
} catch (err) {
setError(err as Error);
setAllowed(false); // Fail closed
} finally {
set(false);
}
};
checkPerm();
}, [user?.id, tenantId, permissionKey]);
return { allowed, , error };
}
Usage in components:
// AIFeatureCard.tsx
export function AIFeatureCard() {
const { allowed, } = usePermission('features:ai:access');
if () return <Skeleton />;
if (!allowed) return null;
return <div className="p-4 bg-blue-50">AI Feature</div>;
}
Clean. Testable. Type-safe.
Caching: The Performance Multiplier #
This burned me: I shipped this without caching, and every component requesting the same permission hammered the backend. Use React Query:
// usePermission.ts (improved)
import { useQuery } from '@tanstack/react-query';
export function usePermission(
permissionKey: PermissionKey,
options: UsePermissionOptions = {}
) {
const { user, tenantId } = useAuth();
const cacheSeconds = options.cacheSeconds ?? 300; // 5 min default
return useQuery({
queryKey: ['permission', tenantId, permissionKey],
queryFn: async () => {
const response = await fetch('/api/permissions/check', {
method: 'POST',
body: JSON.stringify({
permission: permissionKey,
tenant_id: tenantId,
}),
});
const data = await response.json();
return data.allowed;
},
staleTime: cacheSeconds * 1000,
enabled: !!user && !!tenantId,
});
}
Now the second component asking for features:ai:access
hits the cache, not your backend.