UNPKG

@taukala/xs-ctrl

Version:

A flexible and powerful access control library for JavaScript applications with dynamic validation support

517 lines (426 loc) 13 kB
# @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 ```bash npm install @taukala/xs-ctrl ``` ## Basic Concepts ### Static Rules Static rules validate against user claims directly, such as roles or permissions. ```javascript 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. ```javascript 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. ```javascript 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. ```javascript // 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. ```javascript 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. ```javascript // 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 object - `context` (Object, optional): Context passed to dynamic validators #### Validation Logic 1. Empty rules array means no restrictions (returns true) 2. Rule groups are combined with OR logic (user needs to match any group) 3. Conditions within a group use AND logic (user needs to match all conditions) 4. 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 ```javascript // 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 ```javascript 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 ```javascript 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 ```javascript // 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 ```javascript // 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 ```javascript // 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 ```javascript 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 ```javascript 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]