access-control-kit
v0.1.1
Published
Framework-agnostic Policy-based Access Control with React bindings
Maintainers
Readme
Access Control Kit
A powerful, type-safe, and framework-agnostic access control library. Define your policies once and use them anywhere—Node.js, React, Vue, Svelte, or plain JavaScript.
License
MIT
Features
- 🔒 Type-Safe: Automatic type inference for resources and actions based on your configuration.
- 📝 Statement-Based Policy: Granular control with 'allow' and 'deny' effects, similar to AWS IAM.
- 🎯 Specificity-Based Evaluation: More specific statements override broader ones, enabling fine-grained control.
- 🚀 Framework Agnostic Core: Pure JS/TS logic (
access-control-kit) that can be used in any environment. - ⚛️ React Bindings: First-class support for React (
access-control-kit/react) with Providers, Hooks, and Guards. - 🌍 Universal: Share the same policy logic across your entire stack (Backend & Frontend).
- 🎯 Attribute-Based Access Control (ABAC): Support for flexible runtime contexts.
- 🃏 Wildcard Support: Support for
*actions and resources. - 🛡️ Secure Defaults: Default deny policy with explicit allow overrides.
Installation
npm install access-control-kit
# or
yarn add access-control-kit
# or
pnpm add access-control-kitQuick Start
This guide shows you how to set up access-control-kit in both bare JavaScript/TypeScript projects and React applications.
Part 1: Bare JavaScript/TypeScript Project
Perfect for Node.js backends, API routes, serverless functions, or any non-React JavaScript environment.
Step 1: Create Access Control Configuration
Create a centralized access-control.ts file to define your resources, actions, and export utilities.
// access-control.ts
import { getAccessControl, type TAccessControlPolicy } from 'access-control-kit';
// Define your resources and actions
// Use 'as const' to ensure type inference
export const config = {
POST: ['create', 'read', 'update', 'delete'],
USER: ['read', 'invite', 'delete'],
SETTINGS: ['view', 'edit'],
} as const;
// Export the type for use in your app
export type AccessControlPolicy = TAccessControlPolicy<typeof config>;
// Export the getAccessControl utility
export { getAccessControl };Step 2: Fetch or Define User Policy
Create a utility to fetch or generate the user's access policy based on their role, permissions, or attributes.
// utils/getUserPolicy.ts
import type { AccessControlPolicy } from '../access-control';
// Example: Fetch policy from API
export async function fetchUserPolicy(userId: string): Promise<AccessControlPolicy> {
const response = await fetch(`/api/users/${userId}/policy`);
return response.json();
}
// Or define static policies based on roles
export function getRoleBasedPolicy(role: 'admin' | 'editor' | 'viewer'): AccessControlPolicy {
switch (role) {
case 'admin':
return [{ resource: '*', actions: ['*'], effect: 'allow' }];
case 'editor':
return [
{ resource: 'POST', actions: ['create', 'read', 'update'], effect: 'allow' },
{ resource: 'POST', actions: ['delete'], effect: 'allow', contexts: [{ authorId: 'current-user' }] },
{ resource: 'USER', actions: ['read'], effect: 'allow' },
];
case 'viewer':
return [
{ resource: 'POST', actions: ['read'], effect: 'allow' },
{ resource: 'USER', actions: ['read'], effect: 'allow' },
];
default:
return [];
}
}Step 3: Use in Your Application
// Example: Express.js API route
import { getAccessControl } from './access-control';
import { fetchUserPolicy } from './utils/getUserPolicy';
app.post('/api/posts', async (req, res) => {
// Get user policy
const policy = await fetchUserPolicy(req.user.id);
const { can } = getAccessControl(policy);
// Check permission
if (!can('POST', 'create')) {
return res.status(403).json({ error: 'Forbidden' });
}
// Proceed with creation
const post = await createPost(req.body);
res.json(post);
});
// Example: Check with context
app.delete('/api/posts/:id', async (req, res) => {
const policy = await fetchUserPolicy(req.user.id);
const post = await getPost(req.params.id);
const { can } = getAccessControl(policy);
// Check if user can delete (might require ownership)
if (!can('POST', 'delete', { authorId: post.authorId })) {
return res.status(403).json({ error: 'Forbidden' });
}
await deletePost(req.params.id);
res.json({ success: true });
});
// Example: Check multiple permissions
app.get('/api/posts/:id/actions', async (req, res) => {
const policy = await fetchUserPolicy(req.user.id);
const post = await getPost(req.params.id);
const { can, canAll, canAny } = getAccessControl(policy);
res.json({
canRead: can('POST', 'read'),
canUpdate: can('POST', 'update', { authorId: post.authorId }),
canDelete: can('POST', 'delete', { authorId: post.authorId }),
canManage: canAll('POST', ['update', 'delete'], { authorId: post.authorId }),
hasAnyAccess: canAny('POST', ['read', 'update', 'delete']),
});
});Part 2: React Application
Perfect for React, Next.js, or any React-based frontend application.
Step 1: Create Access Control Configuration
Create a centralized access-control.ts file with React-specific exports.
// access-control.ts
import { createAccessControl, type TAccessControlPolicy } from 'access-control-kit/react';
// Define your resources and actions
export const config = {
POST: ['create', 'read', 'update', 'delete'],
USER: ['read', 'invite', 'delete'],
SETTINGS: ['view', 'edit'],
} as const;
// Export the type for use in your app
export type AccessControlPolicy = TAccessControlPolicy<typeof config>;
// Create and export React utilities
export const {
AccessControlProvider,
useAccessControl,
AccessControlGuard,
withAccessControl,
} = createAccessControl(config);Step 2: Setup Provider in Your App
Wrap your application with the AccessControlProvider and fetch the user's policy.
[!NOTE] The
isLoadingprop is only meaningful if your policy is fetched or generated asynchronously. If you have a static policy or it's already available, you can omit this prop (defaults tofalse).
// App.tsx
import { useState, useEffect } from 'react';
import { AccessControlProvider, type AccessControlPolicy } from './access-control';
// Utility to fetch user policy asynchronously
async function fetchUserPolicy(userId: string): Promise<AccessControlPolicy> {
const response = await fetch(`/api/users/${userId}/policy`);
return response.json();
}
function App() {
const [policy, setPolicy] = useState<AccessControlPolicy>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Fetch policy on mount
fetchUserPolicy('current-user-id')
.then(setPolicy)
.finally(() => setIsLoading(false));
}, []);
return (
<AccessControlProvider accessControlPolicy={policy} isLoading={isLoading}>
<Dashboard />
</AccessControlProvider>
);
}Step 3: Use Access Control Utilities
A. Using the useAccessControl Hook
// components/PostActions.tsx
import { useAccessControl } from '../access-control';
interface PostActionsProps {
post: {
id: string;
authorId: string;
title: string;
};
}
function PostActions({ post }: PostActionsProps) {
const { can, canAll, isLoading } = useAccessControl();
if (isLoading) {
// Show skeleton while policy is loading
return (
<div className="post-actions">
<div className="skeleton skeleton-title" />
<div className="skeleton skeleton-button" />
<div className="skeleton skeleton-button" />
</div>
);
}
const canUpdate = can('POST', 'update', { authorId: post.authorId });
const canDelete = can('POST', 'delete', { authorId: post.authorId });
const canManage = canAll('POST', ['update', 'delete'], { authorId: post.authorId });
return (
<div className="post-actions">
<h3>{post.title}</h3>
{canUpdate && (
<button onClick={() => editPost(post.id)}>
Edit Post
</button>
)}
{canDelete && (
<button onClick={() => deletePost(post.id)}>
Delete Post
</button>
)}
{canManage && (
<button onClick={() => openAdvancedSettings(post.id)}>
Advanced Settings
</button>
)}
</div>
);
}B. Using the AccessControlGuard Component
// components/SettingsPage.tsx
import { AccessControlGuard } from '../access-control';
function SettingsPage() {
return (
<div>
<h1>Settings</h1>
{/* Standard mode with fallback */}
<AccessControlGuard
resource="SETTINGS"
action="view"
fallback={<p>You don't have access to view settings.</p>}
loadingFallback={<div className="skeleton skeleton-content" />}
>
<SettingsContent />
</AccessControlGuard>
{/* PassThrough mode (render props) */}
<AccessControlGuard resource="SETTINGS" action="edit" passThrough>
{({ allowed, isLoading }) => (
<button
disabled={!allowed || isLoading}
onClick={saveSettings}
>
{isLoading ? 'Checking...' : allowed ? 'Save Settings' : 'Read Only'}
</button>
)}
</AccessControlGuard>
</div>
);
}C. Using the withAccessControl HOC
// components/AdminPanel.tsx
import { withAccessControl } from '../access-control';
function AdminPanel() {
return (
<div>
<h1>Admin Panel</h1>
<p>Welcome to the admin area!</p>
</div>
);
}
// Protect the entire component
export default withAccessControl(
AdminPanel,
'SETTINGS',
'edit',
undefined,
() => <div>Access Denied: Admin privileges required.</div>,
() => <div className="skeleton skeleton-page" />
);D. Complete Dashboard Example
// components/Dashboard.tsx
import { useAccessControl, AccessControlGuard } from '../access-control';
function Dashboard() {
const { can, canAny, isLoading } = useAccessControl();
if (isLoading) {
return <div className="skeleton skeleton-dashboard" />;
}
return (
<div className="dashboard">
<h1>Dashboard</h1>
{/* Conditional rendering with hook */}
{can('POST', 'create') && (
<button onClick={createNewPost}>
Create New Post
</button>
)}
{/* Guard component for sections */}
<AccessControlGuard
resource="USER"
action="invite"
fallback={<p>You cannot invite users.</p>}
>
<InviteUserForm />
</AccessControlGuard>
{/* Show section if user has ANY post permission */}
{canAny('POST', ['create', 'read', 'update', 'delete']) && (
<PostsSection />
)}
{/* Settings with passThrough */}
<AccessControlGuard resource="SETTINGS" action="view" passThrough>
{({ allowed }) => (
<div className={allowed ? 'settings-enabled' : 'settings-disabled'}>
<h2>Settings</h2>
{allowed ? <SettingsForm /> : <p>Contact admin for access.</p>}
</div>
)}
</AccessControlGuard>
</div>
);
}Summary
- Bare JS/TS: Use
getAccessControl(policy)to getcan,canAll,canAnyutilities - React: Use
createAccessControl(config)to getProvider,useAccessControlhook,AccessControlGuardcomponent, andwithAccessControlHOC - Centralized Config: Export everything from
access-control.tsfor consistency - Type Safety: Use
as conston config for full TypeScript inference
Core API (access-control-kit)
The core module is framework-agnostic and can be used anywhere.
getAccessControl(policy)
Evaluates permissions based on the provided policy.
Parameters:
policy:TAccessControlPolicy<T>- The access control policy to evaluate.
Returns: Object with:
policy: The original policycan(resource, action, context?): Check if a single action is allowedcanAll(resource, actions, context?): Check if ALL actions are allowedcanAny(resource, actions, context?): Check if ANY action is allowed
Example:
import { getAccessControl } from 'access-control-kit';
const policy = [
{ resource: 'POST', actions: ['read', 'update'], effect: 'allow' }
];
const { can, canAll, canAny } = getAccessControl(policy);
can('POST', 'read'); // true
can('POST', 'delete'); // false
canAll('POST', ['read', 'update']); // true
canAny('POST', ['read', 'delete']); // truecan(resource, action, context?)
Check if a specific action on a resource is allowed.
Parameters:
resource: Resource key from your configaction: Action name from your configcontext?: Optional runtime context object or array of objects
Returns: boolean
Examples:
// Simple check
can('POST', 'read'); // true/false
// With single context
can('POST', 'update', { authorId: 'user-123' }); // true if policy allows
// With multiple contexts (OR logic)
can('POST', 'update', [
{ authorId: 'user-123' },
{ role: 'admin' }
]); // true if ANY context matchescanAll(resource, actions, context?)
Check if ALL specified actions are allowed.
Parameters:
resource: Resource keyactions: Array of action namescontext?: Optional context
Returns: boolean
Example:
canAll('POST', ['read', 'update']); // true only if BOTH are allowedcanAny(resource, actions, context?)
Check if ANY of the specified actions are allowed.
Parameters:
resource: Resource keyactions: Array of action namescontext?: Optional context
Returns: boolean
Example:
canAny('POST', ['read', 'delete']); // true if EITHER is allowedReact API (access-control-kit/react)
React-specific bindings with full TypeScript support.
createAccessControl(config)
Factory function to create typed React utilities.
Parameters:
config: Your resource/action configuration object
Returns: Object with:
AccessControlProvider: Context provider componentuseAccessControl: Hook to access permissionsAccessControlGuard: Component guardwithAccessControl: HOC wrapper
Example:
import { createAccessControl } from 'access-control-kit/react';
const config = {
POST: ['create', 'read', 'update', 'delete'],
} as const;
export const {
AccessControlProvider,
useAccessControl,
AccessControlGuard,
withAccessControl,
} = createAccessControl(config);<AccessControlProvider>
Context provider that makes the policy available to child components.
Props:
accessControlPolicy:TAccessControlPolicy<T>- The policy to enforceisLoading?:boolean- Optional loading state (default:false)children:React.ReactNode
Example:
<AccessControlProvider accessControlPolicy={policy} isLoading={isLoading}>
<App />
</AccessControlProvider>useAccessControl()
Hook to access the policy context.
Returns: Object with:
can(resource, action, context?): Check permissioncanAll(resource, actions, context?): Check all permissionscanAny(resource, actions, context?): Check any permissionpolicy: The current policyisLoading: Loading state
Example:
function MyComponent() {
const { can, isLoading } = useAccessControl();
if (isLoading) return <div>Loading...</div>;
return (
<div>
{can('POST', 'update', { authorId: currentUser.id }) && (
<button>Edit Post</button>
)}
</div>
);
}<AccessControlGuard>
Component that conditionally renders children based on permissions.
Props:
resource: Resource keyaction: Action namecontext?: Optional runtime contextfallback?: Content to show if denied (default:null)loadingFallback?: Content to show while loading (default:null)passThrough?: Iftrue, enables render props modechildren: Content to show if allowed, or render function ifpassThrough
Standard Mode:
<AccessControlGuard
resource="POST"
action="update"
context={{ authorId: post.authorId }}
fallback={<span>No Access</span>}
loadingFallback={<span>Checking...</span>}
>
<button>Edit Post</button>
</AccessControlGuard>PassThrough Mode (Render Props):
<AccessControlGuard resource="POST" action="update" passThrough>
{({ allowed, isLoading }) => (
<button disabled={!allowed || isLoading}>
{isLoading ? 'Checking...' : allowed ? 'Edit' : 'Locked'}
</button>
)}
</AccessControlGuard>withAccessControl(Component, resource, action, context?, FallbackComponent?, LoadingComponent?)
Higher-Order Component to protect a component with access control.
Parameters:
Component: Component to wrapresource: Resource keyaction: Action namecontext?: Optional contextFallbackComponent?: Component to show if deniedLoadingComponent?: Component to show while loading
Example:
const ProtectedSettings = withAccessControl(
SettingsPage,
'SETTINGS',
'edit',
undefined,
() => <div>Access Denied</div>,
() => <div>Loading...</div>
);Recipes
Role-Based Access Control (RBAC)
import { TAccessControlPolicy } from 'access-control-kit';
type Role = 'ADMIN' | 'EDITOR' | 'VIEWER';
export function getRolePolicy(role: Role): TAccessControlPolicy<typeof config> {
switch (role) {
case 'ADMIN':
return [{ resource: '*', actions: ['*'], effect: 'allow' }];
case 'EDITOR':
return [
{ resource: 'POST', actions: ['create', 'read', 'update'], effect: 'allow' },
{ resource: 'POST', actions: ['delete'], effect: 'allow', contexts: [{ authorId: 'current-user' }] },
];
case 'VIEWER':
return [{ resource: 'POST', actions: ['read'], effect: 'allow' }];
default:
return [];
}
}Attribute-Based Access Control (ABAC)
// Policy with contexts
const policy = [
{
resource: 'DOCUMENT',
actions: ['view'],
effect: 'allow',
contexts: [
{ department: 'engineering' },
{ public: true }
]
}
];
// Usage - checks if ANY context matches
can('DOCUMENT', 'view', { department: 'engineering' }); // true
can('DOCUMENT', 'view', { public: true }); // true
can('DOCUMENT', 'view', { department: 'sales' }); // falseSpecificity-Based Evaluation
access-control-kit uses specificity-based evaluation where more specific statements (with more context keys) override broader ones. This enables sophisticated access control patterns.
How Specificity Works
Specificity Score: Number of keys in the context object
- No context: specificity = 0
{ userId: 'x' }: specificity = 1{ userId: 'x', postId: 'y' }: specificity = 2
Evaluation Rules:
- More specific statements take precedence
- Among equally specific statements, deny wins
- Statements without context are least specific
Example: Override Broad Deny with Specific Allow
const policy = [
// Broad deny - specificity = 1
{
resource: 'POST',
actions: ['delete'],
effect: 'deny',
contexts: [{ userId: 'restricted-user' }]
},
// Specific allow - specificity = 2 (overrides above)
{
resource: 'POST',
actions: ['delete'],
effect: 'allow',
contexts: [{ userId: 'restricted-user', postId: 'owned-post' }]
}
];
// User can delete their own post despite broad deny
can('POST', 'delete', { userId: 'restricted-user', postId: 'owned-post' }); // ✅ true
// But cannot delete other posts
can('POST', 'delete', { userId: 'restricted-user', postId: 'other-post' }); // ❌ falseExample: Multi-Level Specificity
const policy = [
// Level 0: Deny all (no context)
{ resource: 'DOCUMENT', actions: ['view'], effect: 'deny' },
// Level 1: Allow for department (1 key)
{ resource: 'DOCUMENT', actions: ['view'], effect: 'allow', contexts: [{ department: 'eng' }] },
// Level 2: Deny for specific doc (2 keys)
{ resource: 'DOCUMENT', actions: ['view'], effect: 'deny', contexts: [{ department: 'eng', docId: 'secret' }] }
];
can('DOCUMENT', 'view'); // ❌ false (level 0 deny)
can('DOCUMENT', 'view', { department: 'eng' }); // ✅ true (level 1 allow)
can('DOCUMENT', 'view', { department: 'eng', docId: 'public' }); // ✅ true (level 1 allow)
can('DOCUMENT', 'view', { department: 'eng', docId: 'secret' }); // ❌ false (level 2 deny)Multiple Contexts (OR Logic)
// Check if user has access via multiple potential contexts
const userContexts = [
{ department: 'engineering', role: 'intern' },
{ department: 'sales', role: 'lead' }
];
can('DOCUMENT', 'view', userContexts); // true if ANY context matches policyLoading States
function App() {
const [policy, setPolicy] = useState([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchPolicy().then(p => {
setPolicy(p);
setIsLoading(false);
});
}, []);
return (
<AccessControlProvider accessControlPolicy={policy} isLoading={isLoading}>
<MyComponent />
</AccessControlProvider>
);
}Server-Side Validation
// API Route (Next.js App Router)
import { getAccessControl } from 'access-control-kit';
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
const session = await getSession();
const policy = await getUserPolicy(session.userId);
const post = await getPost(params.id);
const { can } = getAccessControl(policy);
if (!can('POST', 'delete', { authorId: post.authorId })) {
return new Response('Forbidden', { status: 403 });
}
await deletePost(params.id);
return new Response('OK');
}Building Wrappers for Other Frameworks
Since access-control-kit core is framework-agnostic, you can easily build wrappers for Vue, Svelte, Angular, etc.
Vue Example
// useAccessControl.ts
import { getAccessControl } from 'access-control-kit';
import { computed, unref } from 'vue';
export function useAccessControl(policyRef) {
// Re-create access control object when policy changes
const accessControl = computed(() => getAccessControl(unref(policyRef)));
return {
// Delegate to the current access control instance
can: (resource, action, context) => accessControl.value.can(resource, action, context),
canAll: (resource, actions, context) => accessControl.value.canAll(resource, actions, context),
canAny: (resource, actions, context) => accessControl.value.canAny(resource, actions, context),
};
}Svelte Example
// accessControl.ts
import { getAccessControl } from 'access-control-kit';
import { writable, derived } from 'svelte/store';
export function createAccessControlStore(initialPolicy) {
const policy = writable(initialPolicy);
// Derived store that updates whenever policy changes
// Returns { can, canAll, canAny, policy }
const accessControl = derived(policy, $policy => getAccessControl($policy));
return {
policy,
subscribe: accessControl.subscribe, // Make it a store
};
}
// Usage in component:
// <script>
// import { createAccessControlStore } from './accessControl';
// const ac = createAccessControlStore([]);
// </script>
//
// {#if $ac.can('POST', 'read')}...{/if}TypeScript Support
Full TypeScript support with automatic type inference:
const config = {
POST: ['create', 'read', 'update', 'delete'],
USER: ['read', 'invite'],
} as const;
const { can } = createAccessControl(config);
// ✅ TypeScript knows these are valid
can('POST', 'read');
can('USER', 'invite');
// ❌ TypeScript errors
can('POST', 'invalid'); // Error: 'invalid' is not a valid action
can('INVALID', 'read'); // Error: 'INVALID' is not a valid resourceLicense
MIT © Aashish Rai
