1.1.5 • Published 6 months ago
@taukala/xs-ctrl v1.1.5
@taukala/xs-ctrl
A flexible and powerful access control system for JavaScript applications, designed to handle complex authorization patterns including role-based, resource-based, and multi-tenant access control.
Features
- 🛡️ Comprehensive permission validation
- Static role-based validation
- Dynamic resource-based validation
- Mixed validation combining both approaches
- 🏢 Built for multi-tenant applications
- Business-level access control
- Department-level permissions
- Cross-business user management
- 🔄 Fluent API for creating access rules
- 🎯 Support for complex authorization patterns
- 🔌 Easy integration with any authentication system
- 🎨 Customizable unauthorized handling
- 🚀 Framework agnostic
- 💡 Simple and intuitive API
Installation
npm install @taukala/xs-ctrl
Basic Concepts
Static Rules
Static rules validate against user claims directly, such as roles or permissions.
const adminRule = createAccessRule()
.addCondition('role', 'admin')
.build();
const managerRule = createAccessRule()
.addCondition('role', 'manager')
.addCondition('department', 'IT', 'HR')
.build();
Dynamic Rules
Dynamic rules validate against resources, perfect for multi-tenant scenarios.
const businessOwnerRule = createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessOwner || [];
return businessIds.includes(resources.business?.id);
})
.build();
Mixed Rules
Combine static and dynamic validation for complex scenarios.
const businessManagerRule = createAccessRule()
.addCondition('role', 'business')
.addCondition('status', 'active')
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessManager || [];
return businessIds.includes(resources.business?.id);
})
.build();
API Reference
createAccessRule()
Creates a builder for constructing access rules with a fluent API.
// Static validation
const rule = createAccessRule()
.addCondition('role', 'business')
.build();
// Dynamic validation
const rule = createAccessRule()
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessOwner || [];
return businessIds.includes(resources.business?.id);
})
.build();
// Mixed validation
const rule = createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessOwner || [];
return businessIds.includes(resources.business?.id);
})
.addDynamicCondition(({ claims, resources }) => {
return resources.business?.status === 'active';
})
.build();
createPermissionValidator(options)
Creates a permission validator with custom handlers.
const validatePermission = createPermissionValidator({
// Get current session
getSession: async () => {
return await auth();
},
// Get user claims
getClaims: async (session) => {
return {
role: ['business'],
businessOwner: ['business-1', 'business-2'],
businessOperator: ['business-3'],
departmentManager: ['dept-1', 'dept-2']
};
},
onUnauthenticated: () => redirect('/login'),
onUnauthorized: () => redirect('/')
});
validateClaim(accessRules, userClaims, context?)
Core validation function that checks user claims against access rules using OR/AND logic.
// Empty rules - no restrictions
await validateClaim([], userClaims); // Returns true
// Static conditions only
await validateClaim([
{
conditions: [
['role', ['admin']],
['status', ['active']]
]
}
], userClaims);
// Dynamic conditions only
await validateClaim([
{
dynamicValidators: [
({ resources }) => resources.business?.ownerId === resources.user?.id
]
}
], userClaims, { resources });
// Mixed conditions
await validateClaim([
{
conditions: [
['role', ['business']],
['status', ['active']]
],
dynamicValidators: [
({ resources }) => resources.business?.ownerId === resources.user?.id
]
}
], userClaims, { resources });
// Multiple rule groups (OR logic)
await validateClaim([
{
// Group 1: Admin role
conditions: [['role', ['admin']]]
},
{
// Group 2: Business owner
conditions: [['role', ['business']]],
dynamicValidators: [
({ resources }) => resources.business?.ownerId === resources.user?.id
]
}
], userClaims, { resources });
Parameters
accessRules
(Array): Array of rule groups. Each group can contain:conditions
: Array of static claim conditions[claimKey, validValues[]]
dynamicValidators
: Array of functions that return boolean
userClaims
(Object): User's claims objectcontext
(Object, optional): Context passed to dynamic validators
Validation Logic
- Empty rules array means no restrictions (returns true)
- Rule groups are combined with OR logic (user needs to match any group)
- Conditions within a group use AND logic (user needs to match all conditions)
- Each group can have:
- Only static conditions
- Only dynamic conditions
- Both static and dynamic conditions
Returns
- Returns
Promise<boolean>
, Returns true if validation passes - Throws error if validation fails
Usage Examples
Business Owner Access
// Define rules
const businessRules = {
owner: createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessOwner || [];
return businessIds.includes(resources.business?.id);
})
.build(),
operator: createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessOperator || [];
return businessIds.includes(resources.business?.id);
})
.build()
};
// Use in route handler
async function updateBusiness(businessId, data) {
const business = await prisma.business.findUnique({
where: { id: businessId }
});
await validatePermission(
[businessRules.owner],
{ resources: { business } }
);
// Proceed with update
}
Department-Level Access
const departmentRules = {
manager: createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessOwner || [];
return businessIds.includes(resources.business?.id);
})
.addDynamicCondition(({ claims, resources }) => {
const departmentIds = claims.departmentManager || [];
return departmentIds.includes(resources.department?.id);
})
.build()
};
async function updateDepartment(businessId, departmentId, data) {
const [business, department] = await Promise.all([
prisma.business.findUnique({ where: { id: businessId } }),
prisma.department.findUnique({ where: { id: departmentId } })
]);
await validatePermission(
[departmentRules.manager],
{
resources: {
business,
department
}
}
);
// Proceed with update
}
Multi-Business User Access
const multiBusinessRules = {
anyBusiness: createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const ownerIds = claims.businessOwner || [];
const operatorIds = claims.businessOperator || [];
const allowedIds = [...ownerIds, ...operatorIds];
return allowedIds.includes(resources.business?.id);
})
.build()
};
// Dashboard page showing all accessible businesses
async function BusinessDashboard() {
const { claims } = await validatePermission([
multiBusinessRules.anyBusiness
]);
const ownerBusinesses = await prisma.business.findMany({
where: {
id: { in: claims.businessOwner }
}
});
const operatorBusinesses = await prisma.business.findMany({
where: {
id: { in: claims.businessOperator }
}
});
return (
<div>
<h2>Your Businesses</h2>
{/* Render businesses */}
</div>
);
}
Next.js Integration Guide
Setup Permission Validator
// lib/permissions.js
import { createPermissionValidator, createAccessRule } from '@taukala/xs-ctrl';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';
export const validatePermission = createPermissionValidator({
getSession: auth,
getClaims: async (session) => {
if (!session?.user?.id) return {};
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/users/${session.user.id}/claims`,
{ next: { revalidate: 60 } }
);
return response.json();
},
onUnauthenticated: () => redirect('/auth/signin'),
onUnauthorized: () => redirect('/dashboard')
});
// Define business-related rules
export const businessRules = {
owner: createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessOwner || [];
return businessIds.includes(resources.business?.id);
})
.build(),
operator: createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const businessIds = claims.businessOperator || [];
return businessIds.includes(resources.business?.id);
})
.build(),
departmentManager: createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
const departmentIds = claims.departmentManager || [];
return departmentIds.includes(resources.department?.id);
})
.build()
};
Protected Routes
// app/business/[businessId]/page.js
import { validatePermission, businessRules } from '@/lib/permissions';
async function getBusinessResource(businessId) {
return await prisma.business.findUnique({
where: { id: businessId }
});
}
export default async function BusinessPage({ params }) {
const business = await getBusinessResource(params.businessId);
const { session, claims } = await validatePermission(
[businessRules.owner, businessRules.operator],
{ resources: { business } }
);
const isOwner = claims.businessOwner?.includes(business.id);
return (
<div>
<h1>{business.name}</h1>
{isOwner && <EditBusinessButton />}
{/* Other business content */}
</div>
);
}
Server Actions
// actions/updateBusiness.js
import { validatePermission, businessRules } from '@/lib/permissions';
async function getResources(businessId) {
const [business, departments] = await Promise.all([
prisma.business.findUnique({
where: { id: businessId }
}),
prisma.department.findMany({
where: { businessId }
})
]);
return { business, departments };
}
export async function updateBusiness(businessId, data) {
const resources = await getResources(businessId);
await validatePermission(
[businessRules.owner],
{ resources }
);
// Proceed with update
return prisma.business.update({
where: { id: businessId },
data
});
}
Advanced Usage
Combining Multiple Rules
const complexRule = createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
// Check business ownership
const businessIds = claims.businessOwner || [];
return businessIds.includes(resources.business?.id);
})
.addDynamicCondition(({ claims, resources }) => {
// Check subscription status
return resources.business?.subscriptionStatus === 'active';
})
.addDynamicCondition(({ claims, resources }) => {
// Check feature access
const features = claims.features || [];
return features.includes(resources.feature?.id);
})
.build();
Resource-Based Validation
const projectRule = createAccessRule()
.addCondition('role', 'business')
.addDynamicCondition(({ claims, resources }) => {
// Check business access
const businessIds = claims.businessOwner || [];
return businessIds.includes(resources.project?.businessId);
})
.addDynamicCondition(({ claims, resources }) => {
// Check project access
const projectIds = claims.projectManager || [];
return projectIds.includes(resources.project?.id);
})
.build();
// Usage
async function updateProject(projectId, data) {
const project = await prisma.project.findUnique({
where: { id: projectId },
include: { business: true }
});
await validatePermission(
[projectRule],
{
resources: {
project,
business: project.business
}
}
);
// Proceed with update
}
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
MIT © Taukala Sdn Bhd