can-already
v1.4.0
Published
High-performance TypeScript authorization library with O(1) permission checks
Maintainers
Readme
CanAlready
A high-performance TypeScript authorization library with O(1) permission checks, serving as a drop-in replacement for CanCan.
Features
- O(1) Performance: Constant-time permission lookups using object storage
- Dual-Generic Architecture: Separate types for clean definitions and rich runtime context
- Multi-Role Support: Check permissions for users with multiple roles
- Strong TypeScript Support: Fully typed with generic support for custom roles, actions, and resources
- CanCan Compatibility: Drop-in replacement with familiar API
- Wildcard Support:
*andmanagewildcard permissions - Condition Functions: Dynamic permission evaluation with runtime context
- Export/Import: Serialize and transfer permission sets
- Debug Mode: Detailed logging for development
- Zero Dependencies: Minimal bundle size
Installation
npm install can-alreadyQuick Start
Traditional Approach (CanCan Compatible)
import { CanAlready } from 'can-already';
// Define your types
enum UserRole { ADMIN = 'admin', USER = 'user' }
enum UserAction { READ = 'read', WRITE = 'write', DELETE = 'delete' }
enum UserResource { POST = 'post', PROFILE = 'profile' }
// Create instance
const canAlready = new CanAlready<UserRole, UserAction, UserResource>({
roleResolver: (role) => role.toString(),
actionResolver: (action) => action.toString(),
resourceResolver: (resource) => resource.toString(),
errorFactory: (message, allowedRoles) =>
new Error(`${message}. Allowed roles: ${allowedRoles.join(', ')}`)
});
// Use destructured interface (CanCan compatible)
const { allow, can, cannot, authorize } = canAlready;
// Define permissions
allow(UserRole.ADMIN, '*', '*');
allow(UserRole.USER, UserAction.READ, UserResource.POST);
// Check permissions
can(UserRole.USER, UserAction.READ, UserResource.POST); // true
cannot(UserRole.USER, UserAction.DELETE, UserResource.POST); // trueFor object-level checks (conditions that inspect the record), pass the actual record as the third argument — see Enhanced Approach.
Enhanced Approach (Dual-Generic, record-first)
Pass the actual record you are authorizing as the third argument. The
resourceResolver derives the record's type key (used for the O(1) lookup), and
condition functions receive that same record object as their third parameter — so they
read fields straight off it. No side-context bundle needed.
import { CanAlready } from 'can-already';
interface UserContext {
userId: string;
role: string;
organisationId: string;
}
interface Team {
type: 'team';
id: string;
organisationId: string;
}
// Clean permission definitions, rich runtime context
const canAlready = new CanAlready<string, UserContext, string, Team>({
roleResolver: (role) => typeof role === 'string' ? role : role.role,
actionResolver: (action) => action,
// resolver returns the record's TYPE, not its identity
resourceResolver: (record) => record.type,
errorFactory: (message, allowedRoles) => new Error(`${message}. Allowed: ${allowedRoles.join(', ')}`)
});
// Clean, readable permission definitions
canAlready.allow('admin', '*', '*');
canAlready.allow('user', 'read', 'post');
// condition reads fields directly off the record (4th param unused)
canAlready.allow('manager', 'manage', 'team', (user, action, team) =>
user.organisationId === team.organisationId
);
// Authorize the real record
const user = { userId: '123', role: 'manager', organisationId: 'acme-corp' };
const team = { type: 'team', id: 't1', organisationId: 'acme-corp' };
canAlready.can(user, 'delete', team); // trueThe 4th
optionsargument still exists for genuinely external context (see Call forms), but the record itself belongs in the 3rd argument — not smuggled intooptions.record.
Advanced Usage
Dual-Generic Architecture
CanAlready supports separate types for permission definitions and runtime evaluation, enabling clean, readable permission definitions while providing rich context for authorization checks:
import { CanAlready } from 'can-already';
interface UserRole {
userId: string;
role: string;
organisationId: string;
}
const canAlready = new CanAlready<string, UserRole, string, Post>({
roleResolver: (role) => typeof role === 'string' ? role : role.role,
actionResolver: (action) => action,
// derive the TYPE key from the record
resourceResolver: (record) => record.type,
errorFactory: (message, allowedRoles) => new Error(`${message}. Allowed: ${allowedRoles.join(', ')}`)
});
// Clean, readable permission definitions using simple strings
const { allow, can, cannot, authorize } = canAlready;
allow('ADMIN', '*', '*');
allow('MODERATOR', 'manage', 'post', isSameOrganisation);
allow('USER', 'read', 'post');
// Condition receives the runtime role and the actual record
const isSameOrganisation = (role: UserRole, action: string, post: Post) =>
role.organisationId === post.organisationId;
// Runtime calls: pass the record you fetched
const userContext = { userId: '123', role: 'moderator', organisationId: 'org1' };
authorize(userContext, 'delete', targetPost);Complex Authorization Scenarios
The dual-generic architecture excels at complex, real-world authorization scenarios:
// Define permissions with simple, readable strings
allow('MANAGER', 'read', 'reports', isManagerInSameOrg);
allow('USER', 'edit', 'document', isOwnerOrManager);
allow('ADMIN', '*', '*'); // Admins can do everything
// Condition functions receive the runtime role and the actual record
const isManagerInSameOrg = (user: UserRole, action: string, report: any) => {
return user.role === 'manager' &&
user.organisationId === report.organisationId;
};
const isOwnerOrManager = (user: UserRole, action: string, doc: any) => {
return user.userId === doc.ownerId ||
(user.role === 'manager' && user.organisationId === doc.organisationId);
};
// Runtime evaluation with the fetched records
const manager = { userId: '1', role: 'manager', organisationId: 'acme-corp' };
const employee = { userId: '2', role: 'user', organisationId: 'acme-corp' };
// Manager can read reports in their organization
can(manager, 'read', { type: 'reports', organisationId: 'acme-corp' }); // true
// Employee can edit their own documents
can(employee, 'edit', { type: 'document', ownerId: '2', organisationId: 'acme-corp' }); // true
// Multi-role users get permissions from any of their roles
can([manager, employee], 'read', { type: 'reports', organisationId: 'acme-corp' }); // true (manager role grants access)Multi-Role Support
Users can have multiple roles and CanAlready will check all roles for permissions:
// Check permissions for multiple roles
can([UserRole.USER, UserRole.MODERATOR], UserAction.DELETE, UserResource.COMMENT); // true if ANY role has permission
// Works with all permission methods
cannot([UserRole.USER, UserRole.GUEST], UserAction.WRITE, UserResource.POST);
authorize([UserRole.USER, UserRole.MODERATOR], UserAction.READ, UserResource.POST);
// Maintains O(1) performance per role
const userRoles = [UserRole.USER, UserRole.PREMIUM, UserRole.BETA];
can(userRoles, UserAction.READ, UserResource.FEATURE); // Still very fast!Condition Functions
Conditions receive (role, action, resource, options). The resource is the record you
passed as the 3rd argument — read fields off it directly. Reach for the 4th options
argument only for context that is not part of the record.
// Dynamic permission: user may update their own profile
allow('user', 'update', 'profile',
(user, action, profile) => user.id === profile.userId
);
// Pass the actual profile record
const user = { id: 1, role: 'user' };
const profile = { type: 'profile', userId: 1 };
can(user, 'update', profile); // trueWhen a check genuinely needs external context alongside the record (e.g. a target OU that
is not a field on the record), put it in the 4th options argument:
allow('user', 'move', 'document',
(user, action, doc, options) =>
doc.ownerId === user.id && options?.targetFolderId != null
);
can(user, 'move', doc, { targetFolderId: 'f-42' });Complex Object Resolvers
The resourceResolver must return the record's type, not its identity. Keying on the
instance id (e.g. `post_${post.id}`) would register a separate permission entry per
record, so an allow on one post would never match a can on another.
interface User { id: number; role: string; }
interface Post { id: number; authorId: number; }
const canAlready = new CanAlready<User, string, Post>({
roleResolver: (user) => user.role,
actionResolver: (action) => action,
resourceResolver: (post) => 'post', // TYPE key, shared by all posts
errorFactory: (message, allowedRoles) => new Error(message)
});
const author = { id: 1, role: 'author' };
const post = { id: 123, authorId: 1 };
// The condition reads the record; per-record scoping lives here, not in the resolver.
canAlready.allow('author', 'update', 'post', (u, a, p) => u.id === p.authorId);
canAlready.can(author, 'update', post); // trueExport/Import Permissions
// Export permissions for specific roles
const permissions = canAlready.exportPermissions([UserRole.USER, UserRole.ADMIN]);
// Import to another instance
const newCanAlready = new CanAlready(options);
newCanAlready.importPermissions(permissions);Debug Mode
const canAlready = new CanAlready({
debug: true, // Enable debug logging
// ... other options
});
// Logs detailed information about each permission check
can(UserRole.USER, UserAction.READ, UserResource.POST);API Reference
CanAlready Class
class CanAlready<DefinitionRole = string, RuntimeRole = DefinitionRole, Action = string, Resource = string>Enhanced single class supporting both traditional single-type usage and dual-generic architecture:
- Single-type usage:
CanAlready<UserRole>(fully backward compatible with CanCan) - Dual-generic usage:
CanAlready<string, UserContext>(clean definitions, rich runtime context)
Constructor Options
interface CanAlreadyOptions<Role, Action, Resource> {
debug?: boolean;
roleResolver: (role: Role) => string;
actionResolver: (action: Action) => string;
resourceResolver: (resource: Resource) => string;
errorFactory: (message: string, allowedRoles: string[]) => Error;
conditionExporter?: (fn: Function) => string;
conditionImporter?: (fnName: string) => Function | undefined;
}Methods
Permission Definition
allow(definitionRole | definitionRole[], action, resource, condition?)- Define permissions using definition types (e.g., simple strings)
Runtime Authorization
can(runtimeRole | runtimeRole[], action, resource, options?)- Check permissions using runtime types (e.g., user context objects)cannot(runtimeRole | runtimeRole[], action, resource, options?)- Inverse ofcan()authorize(runtimeRole | runtimeRole[], action, resource | resource[], options?)- Likecan()but throws error if access denied. Pass an array of resources to authorize a whole collection: every resource must pass (AND), and it throws on the first denied one. An empty array passes (nothing to authorize).assertAuthorizable(runtimeRole | runtimeRole[], action, resourceType)- Condition-blind pre-gate. See below.
Pre-gating before you load records: assertAuthorizable
authorize() needs the actual record because it runs the rule's condition function. But sometimes
you want to reject a forbidden request before querying — so that an empty result set can't leak
whether a resource exists (e.g. via a 404 vs 403).
assertAuthorizable(role, action, resourceType) answers a coarser question: "is action on
resourceType reachable by any of these roles at all, ignoring per-record conditions?" It passes
iff at least one supplied role has a matching allow() rule registered — condition functions are
never invoked. On failure it throws the same error as authorize()'s denial (identical type
and allowedRoles).
Its 3rd argument is a type token (e.g. a model class), typed distinctly from the record
authorize() takes — so you can't accidentally swap them (either direction is a compile error):
// Pre-gate on the TYPE before touching the database
assertAuthorizable(user, 'list', sequelize.models.Post);
// Only now load records, then authorize each one (conditions run here)
const posts = await sequelize.models.Post.findAll({ where: { authorId: user.id } });
authorize(user, 'list', posts);Necessary but not sufficient: assertAuthorizable is only a fast gate. authorize() remains
the authoritative, record-bound decision.
When the type token's type differs from your record type, supply resourceTypeResolver to map the
token to its storage key (it falls back to resourceResolver when they are the same type):
const canAlready = new CanAlready<string, UserContext, string, Post, typeof PostModel>({
// ...other resolvers
resourceResolver: (record) => record.type, // record instance -> type key
resourceTypeResolver: (model) => model.tableName, // type token -> type key
errorFactory: (message, allowedRoles) => new Error(message),
});Data Management
exportPermissions(definitionRoles[])- Export permissions for specified roles as JSON stringimportPermissions(permissionsJson)- Import permissions from JSON string
Resolving the resource
The 3rd argument to can/cannot/authorize is the actual resource being authorized —
pass the record you fetched, not a string describing its type.
resourceResolver(resource)must return the resource's type key (e.g.'post'), never its identity. The key drives the O(1) lookup; identity-based keys breakallow/canmatching.- Condition functions receive that same resource object as their 3rd parameter — read fields off
it directly (
(user, action, post) => user.id === post.authorId). - The 4th
optionsargument is for context that is not part of the record (e.g. a target location). Do not put the record itself inoptions.
Accepted call forms:
| Form | When |
| --- | --- |
| authorize(user, action, record) | Canonical. The record carries everything the condition needs. |
| authorize(user, action, record, { ...ctx }) | Record + external context a condition needs. |
| authorize(user, action, 'type') | Type-only checks with no per-record condition (still supported). |
The bare-string form remains valid for coarse, type-level permissions, but prefer passing the record whenever a condition inspects it.
Wildcard Support
"*"- Universal wildcard for any role, action, or resource"manage"- Action wildcard (backwards compatibility with CanCan)
allow(UserRole.ADMIN, '*', '*'); // Admin can do anything
allow(UserRole.MODERATOR, 'manage', UserResource.POST); // Moderator can manage posts
allow('*', UserAction.READ, UserResource.POST); // Anyone can read postsPerformance
CanAlready is optimized for O(1) permission checks:
- Direct object property access for lookups
- No iteration through permission lists
- Consistent performance regardless of permission set size
- Memory usage scales linearly with permission count
- Dual-generic architecture has zero runtime overhead - type separation happens at compile time
Migration from CanCan
CanAlready is designed as a drop-in replacement:
// CanCan
const CanCan = require('cancan');
const cancan = new CanCan();
const { allow, can, cannot, authorize } = cancan;
// CanAlready
import { CanAlready } from 'can-already';
const canAlready = new CanAlready(options);
const { allow, can, cannot, authorize } = canAlready;
// Same API, better performance!License
MIT
Contributing
Contributions welcome! Please read our contributing guidelines and submit pull requests.
