diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..7325942 --- /dev/null +++ b/USAGE.md @@ -0,0 +1,456 @@ +# Permask Usage Guide + +Permask is a flexible permission management system with a fluent API. This guide provides examples of how to use the library for common permission management scenarios. + +## Table of Contents +- [Basic Setup](#basic-setup) +- [Creating Permissions](#creating-permissions) +- [Checking Permissions](#checking-permissions) +- [Permission Sets](#permission-sets) +- [String Conversion](#string-conversion) +- [Custom Permissions](#custom-permissions) +- [Advanced Usage](#advanced-usage) + +## Basic Setup + +### Simple CRUD Permissions + +```typescript +import { DefaultPermissionAccess, PermaskBuilder } from './permask-class'; + +// Create a permask instance with default CRUD permissions +const permask = new PermaskBuilder({ + permissions: DefaultPermissionAccess, + groups: { + USERS: 1, + DOCUMENTS: 2, + PHOTOS: 3 + } +}).build(); +``` + +### Custom Permissions + +```typescript +import { PermaskBuilder } from './permask-class'; + +// Define custom permissions +const permask = new PermaskBuilder<{ + VIEW: number; + EDIT: number; + DELETE: number; + SHARE: number; + PRINT: number; +}>({ + permissions: { + VIEW: 1, // 0b00001 + EDIT: 2, // 0b00010 + DELETE: 4, // 0b00100 + SHARE: 8, // 0b01000 + PRINT: 16, // 0b10000 + }, + accessBits: 6, + groups: { + DOCUMENTS: 1, + PHOTOS: 2, + VIDEOS: 3, + SPREADSHEETS: 4 + } +}).build(); +``` + +### Auto-assigned Permission Values + +```typescript +import { PermaskBuilder } from './permask-class'; + +// Let the library auto-assign bit values to permissions +const permask = new PermaskBuilder<{ + VIEW: number; + EDIT: number; + DELETE: number; + SHARE: number; +}>({ + permissions: { + VIEW: null, // Will be assigned 1 + EDIT: null, // Will be assigned 2 + DELETE: null, // Will be assigned 4 + SHARE: null, // Will be assigned 8 + }, + accessBits: 5, + groups: { + DOCUMENTS: 1, + PHOTOS: 2 + } +}).build(); +``` + +## Creating Permissions + +### Basic Permission Creation + +```typescript +// Create a permission for a user to view and edit documents +const documentPermission = permask.for('DOCUMENTS') + .grant(['VIEW', 'EDIT']) + .value(); + +// Create a permission for a user to view photos +const photoPermission = permask.for('PHOTOS') + .grant(['VIEW']) + .value(); + +// Using numeric group IDs +const spreadsheetPermission = permask.for(4) // SPREADSHEETS group + .grant(['VIEW', 'EDIT', 'SHARE']) + .value(); +``` + +### Creating All-Access Permissions + +```typescript +// Grant all possible permissions for documents +const fullDocumentAccess = permask.for('DOCUMENTS') + .grantAll() + .value(); + +// Alternative using ALL permission +const fullPhotoAccess = permask.for('PHOTOS') + .grant(['ALL']) + .value(); +``` + +## Checking Permissions + +### Basic Permission Checking + +```typescript +// Check if the user can view documents +if (permask.check(userPermission).can('VIEW')) { + // Allow viewing +} + +// Check if the user can edit documents +if (permask.check(userPermission).can('EDIT')) { + // Allow editing +} + +// Check if the user is in the DOCUMENTS group +if (permask.check(userPermission).inGroup('DOCUMENTS')) { + // This is a document-related permission +} +``` + +### Multiple Permission Checks + +```typescript +// Check if user has both VIEW and EDIT permissions +if (permask.check(userPermission).canAll(['VIEW', 'EDIT'])) { + // Allow both viewing and editing +} + +// Check if user has either VIEW or EDIT permission +if (permask.check(userPermission).canAny(['VIEW', 'EDIT'])) { + // Allow either viewing or editing +} + +// Check if user has all possible permissions +if (permask.check(userPermission).canEverything()) { + // User has all permissions +} +``` + +### CRUD-Specific Checks + +```typescript +// Using default CRUD permissions +if (permask.check(userPermission).canCreate()) { + // Allow creation +} + +if (permask.check(userPermission).canRead()) { + // Allow reading +} + +if (permask.check(userPermission).canUpdate()) { + // Allow updating +} + +if (permask.check(userPermission).canDelete()) { + // Allow deletion +} +``` + +### Getting Detailed Permission Information + +```typescript +// Get a detailed explanation of permissions +const details = permask.check(userPermission).explain(); + +console.log(details); +// Example output: +// { +// group: 2, +// groupName: "PHOTOS", +// permissions: { +// VIEW: true, +// EDIT: true, +// DELETE: false, +// SHARE: false, +// PRINT: false, +// ALL: false +// } +// } +``` + +## Permission Sets + +### Defining Permission Sets + +```typescript +// Define permission sets during initialization +const permask = new PermaskBuilder<{ + VIEW: number; + EDIT: number; + DELETE: number; + SHARE: number; + PRINT: number; +}>({ + permissions: { + // ... permissions definition + }, + // ... other options +}) +.definePermissionSet('VIEWER', ['VIEW']) +.definePermissionSet('EDITOR', ['VIEW', 'EDIT']) +.definePermissionSet('MANAGER', ['VIEW', 'EDIT', 'DELETE']) +.definePermissionSet('ADMIN', ['VIEW', 'EDIT', 'DELETE', 'SHARE', 'PRINT']) +.build(); +``` + +### Using Permission Sets + +```typescript +// Grant permissions using predefined sets +const viewerPermission = permask.for('DOCUMENTS') + .grantSet('VIEWER') + .value(); + +const editorPermission = permask.for('DOCUMENTS') + .grantSet('EDITOR') + .value(); + +const managerPermission = permask.for('DOCUMENTS') + .grantSet('MANAGER') + .value(); + +// Combine permission sets with additional permissions +const customPermission = permask.for('DOCUMENTS') + .grantSet('EDITOR') + .grant(['SHARE']) + .value(); +``` + +## String Conversion + +### Converting Permissions to Strings + +```typescript +// Convert permissions to strings for storage +const permissionString = permask.toString(userPermission); + +console.log(permissionString); +// Example outputs: +// "DOCUMENTS:VIEW,EDIT" +// "PHOTOS:ALL" +// "VIDEOS:NONE" +``` + +### Converting Strings to Permissions + +```typescript +// Parse permission strings back to numeric values +const docPermission = permask.fromString('DOCUMENTS:VIEW,EDIT'); +const photoPermission = permask.fromString('PHOTOS:ALL'); +const videoPermission = permask.fromString('VIDEOS:MANAGER'); + +// Using numeric group IDs +const customPermission = permask.fromString('5:VIEW,SHARE'); + +// Alternative formats +const allPermission = permask.fromString('DOCUMENTS:*'); +const noPermission = permask.fromString('VIDEOS:'); +``` + +## Custom Permissions + +### Extending Permissions + +```typescript +// Create extended version of the permissions system +const extendedPermask = permask.toBuilder() + .definePermission('APPROVE', 32) + .defineGroup('REPORTS', 5) + .definePermissionSet('APPROVER', ['VIEW', 'APPROVE']) + .build(); + +// Use the new components +const reportPermission = extendedPermask.for('REPORTS') + .grantSet('APPROVER') + .value(); +``` + +## Advanced Usage + +### Combining Multiple Permissions + +```typescript +// User may have separate permissions for different resource types +const userPermissions = { + documents: permask.for('DOCUMENTS').grantSet('EDITOR').value(), + photos: permask.for('PHOTOS').grantSet('VIEWER').value(), + videos: permask.for('VIDEOS').grant(['VIEW', 'SHARE']).value() +}; + +// Check permissions for specific resources +function checkDocumentAccess(action) { + const perm = userPermissions.documents; + + switch(action) { + case 'view': + return permask.check(perm).can('VIEW'); + case 'edit': + return permask.check(perm).can('EDIT'); + case 'delete': + return permask.check(perm).can('DELETE'); + default: + return false; + } +} +``` + +### Role-Based Access Control + +```typescript +// Define roles as permission sets +const rolePermissions = { + user: permask.for('DOCUMENTS').grantSet('VIEWER').value(), + editor: permask.for('DOCUMENTS').grantSet('EDITOR').value(), + admin: permask.for('DOCUMENTS').grantAll().value() +}; + +// Assign roles to users +const users = { + 'alice': { name: 'Alice', role: 'admin' }, + 'bob': { name: 'Bob', role: 'editor' }, + 'charlie': { name: 'Charlie', role: 'user' } +}; + +// Check if a user can perform an action +function canUserPerform(username, action) { + const user = users[username]; + if (!user) return false; + + const permission = rolePermissions[user.role]; + if (!permission) return false; + + switch(action) { + case 'view': + return permask.check(permission).can('VIEW'); + case 'edit': + return permask.check(permission).can('EDIT'); + case 'delete': + return permask.check(permission).can('DELETE'); + default: + return false; + } +} + +// Example usage +console.log(canUserPerform('alice', 'delete')); // true (admin can delete) +console.log(canUserPerform('bob', 'edit')); // true (editor can edit) +console.log(canUserPerform('charlie', 'edit')); // false (user cannot edit) +``` + +### Resource-Based Permissions + +```typescript +// Define permissions for specific resource IDs +function getResourcePermission(resourceType, resourceId, userId) { + // This would typically come from a database + const resourcePermissions = { + // Format: [resourceType:resourceId:userId] -> permission + 'document:123:alice': permask.for('DOCUMENTS').grantAll().value(), + 'document:123:bob': permask.for('DOCUMENTS').grantSet('EDITOR').value(), + 'document:456:bob': permask.for('DOCUMENTS').grantSet('VIEWER').value(), + }; + + const key = `${resourceType}:${resourceId}:${userId}`; + return resourcePermissions[key] || permask.for('DOCUMENTS').grant([]).value(); // No permissions by default +} + +// Check if a user can perform an action on a specific resource +function canAccessResource(userId, resourceType, resourceId, action) { + const permission = getResourcePermission(resourceType, resourceId, userId); + + switch(action) { + case 'view': + return permask.check(permission).can('VIEW'); + case 'edit': + return permask.check(permission).can('EDIT'); + case 'delete': + return permask.check(permission).can('DELETE'); + case 'share': + return permask.check(permission).can('SHARE'); + default: + return false; + } +} + +// Example usage +console.log(canAccessResource('alice', 'document', '123', 'delete')); // true +console.log(canAccessResource('bob', 'document', '123', 'edit')); // true +console.log(canAccessResource('bob', 'document', '456', 'edit')); // false +``` + +### Persisting Permissions + +```typescript +// Store permissions in a database +function saveUserPermissions(userId, permissions) { + // Convert permissions to strings for storage + const serializedPermissions = {}; + + for (const [resource, permission] of Object.entries(permissions)) { + serializedPermissions[resource] = permask.toString(permission); + } + + // In a real application, you would save this to a database + console.log(`Saving permissions for user ${userId}:`, serializedPermissions); + return serializedPermissions; +} + +// Retrieve and parse permissions from storage +function loadUserPermissions(userId, serializedPermissions) { + // In a real application, you would load this from a database + const permissions = {}; + + for (const [resource, permissionString] of Object.entries(serializedPermissions)) { + permissions[resource] = permask.fromString(permissionString); + } + + return permissions; +} + +// Example usage +const userPermissions = { + documents: permask.for('DOCUMENTS').grantSet('EDITOR').value(), + photos: permask.for('PHOTOS').grantSet('VIEWER').value() +}; + +const serialized = saveUserPermissions('alice', userPermissions); +// Example output: { documents: 'DOCUMENTS:VIEW,EDIT', photos: 'PHOTOS:VIEW' } + +const loadedPermissions = loadUserPermissions('alice', serialized); +// loadedPermissions will contain the numeric bitmask values +``` diff --git a/playground/package.json b/playground/package.json new file mode 100644 index 0000000..24a3eba --- /dev/null +++ b/playground/package.json @@ -0,0 +1,14 @@ +{ + "name": "playground", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx run.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "permask": "workspace:*" + } +} diff --git a/playground/run.ts b/playground/run.ts new file mode 100644 index 0000000..c0b53f4 --- /dev/null +++ b/playground/run.ts @@ -0,0 +1,167 @@ +import { DefaultPermissionAccess, PermaskBuilder } from 'permask'; + +console.log('=== Permask API Usage Examples ===\n'); + +// Example 1: Using default CRUD permissions +console.log('== Example 1: Default CRUD Permissions =='); + +const crudPermask = new PermaskBuilder({ + permissions: DefaultPermissionAccess, + groups: { + USERS: 1, + DOCUMENTS: 2, + PHOTOS: 3 + } +}).build(); + +// Create a permission for documents with read and write access +const documentPermission = crudPermask.for('DOCUMENTS') + .grant(['READ', 'CREATE']) + .value(); + +console.log('Document Permission Details:', crudPermask.check(documentPermission).explain()); +console.log('Can Read:', crudPermask.check(documentPermission).canRead()); // true +console.log('Can Write:', crudPermask.check(documentPermission).canCreate()); // true +console.log('Can Delete:', crudPermask.check(documentPermission).canDelete()); // false +console.log('String Representation:', crudPermask.toString(documentPermission)); // "DOCUMENTS:READ,WRITE" + +// Parse from string +const parsedPermission = crudPermask.fromString('PHOTOS:CREATE,READ,UPDATE'); +console.log('Parsed Permission Details:', crudPermask.check(parsedPermission).explain()); + +console.log('\n== Example 2: Custom Permissions =='); + +// Example 2: Custom permissions with named groups +const customPermask = new PermaskBuilder<{ + VIEW: number; + EDIT: number; + DELETE: number; + SHARE: number; + PRINT: number; + DOWNLOAD: number; +}>({ + permissions: { + VIEW: 1, // 0b000001 + EDIT: 2, // 0b000010 + DELETE: 4, // 0b000100 + SHARE: 8, // 0b001000 + PRINT: 16, // 0b010000 + DOWNLOAD: 32, // 0b100000 + }, + accessBits: 8, + groups: { + DOCUMENTS: 1, + PHOTOS: 2, + VIDEOS: 3, + FILES: 4, + ADMIN: 100 + } +}) +.definePermissionSet('VIEWER', ['VIEW', 'DOWNLOAD']) +.definePermissionSet('EDITOR', ['VIEW', 'EDIT', 'DOWNLOAD']) +.definePermissionSet('MANAGER', ['VIEW', 'EDIT', 'DELETE', 'SHARE']) +.definePermissionSet('ADMIN', ['VIEW', 'EDIT', 'DELETE', 'SHARE', 'PRINT', 'DOWNLOAD']) +.build(); + +// Create permission using specific permissions +const editorPermission = customPermask.for('DOCUMENTS') + .grant(['VIEW', 'EDIT', 'SHARE']) + .value(); + +console.log('Editor Permission Details:', customPermask.check(editorPermission).explain()); + +// Check specific permissions +console.log('Can View:', customPermask.check(editorPermission).can('VIEW')); // true +console.log('Can Delete:', customPermask.check(editorPermission).can('DELETE')); // false +console.log('Can View and Edit:', customPermask.check(editorPermission).canAll(['VIEW', 'EDIT'])); // true +console.log('Can Delete or Print:', customPermask.check(editorPermission).canAny(['DELETE', 'PRINT'])); // false + +// Create permission using a permission set +const managerPermission = customPermask.for('PHOTOS') + .grantSet('MANAGER') + .value(); + +console.log('\nManager Permission Details:', customPermask.check(managerPermission).explain()); + +// Grant all permissions +const adminPermission = customPermask.for('ADMIN') + .grantAll() + .value(); + +console.log('\nAdmin Permission Details:', customPermask.check(adminPermission).explain()); +console.log('Has all permissions:', customPermask.check(adminPermission).canEverything()); // true +console.log('String Representation:', customPermask.toString(adminPermission)); // "ADMIN:ALL" + +console.log('\n== Example 3: Permission Sets and Extensions =='); + +// Extend the permissions system +const extendedPermask = customPermask.toBuilder() + .definePermission('APPROVE', 64) + .defineGroup('REPORTS', 101) + .definePermissionSet('APPROVER', ['VIEW', 'APPROVE']) + .build(); + +// Create permission using new components +const reportPermission = extendedPermask.for('REPORTS') + .grantSet('APPROVER') + .value(); + +console.log('Report Permission Details:', extendedPermask.check(reportPermission).explain()); + +// Combine permission sets with individual permissions +const customPermission = extendedPermask.for('DOCUMENTS') + .grantSet('EDITOR') + .grant(['APPROVE']) + .value(); + +console.log('\nCustom Permission Details:', extendedPermask.check(customPermission).explain()); + +console.log('\n== Example 4: String Conversion =='); + +// Convert to string and back +const permissionString = extendedPermask.toString(customPermission); +console.log('Permission String:', permissionString); + +const reconvertedPermission = extendedPermask.fromString(permissionString); +console.log('Reconverted Permission Details:', extendedPermask.check(reconvertedPermission).explain()); + +// Special string formats +console.log('\nSpecial String Formats:'); +console.log('From "DOCUMENTS:ALL":', extendedPermask.check(extendedPermask.fromString('DOCUMENTS:ALL')).explain()); +console.log('From "VIDEOS:*":', extendedPermask.check(extendedPermask.fromString('VIDEOS:*')).explain()); +console.log('From "PHOTOS:VIEWER":', extendedPermask.check(extendedPermask.fromString('PHOTOS:VIEWER')).explain()); + +console.log('\n== Example 5: Auto-assigned Permission Values =='); + +// Auto-assign permission values +const autoPermask = new PermaskBuilder<{ + READ: number; + WRITE: number; + EXECUTE: number; + CONFIGURE: number; +}>({ + permissions: { + READ: null, // Will be auto-assigned to 1 + WRITE: null, // Will be auto-assigned to 2 + EXECUTE: null, // Will be auto-assigned to 4 + CONFIGURE: null, // Will be auto-assigned to 8 + }, + groups: { + FILES: 1, + PROGRAMS: 2, + SETTINGS: 3 + } +}).build(); + +const filePermission = autoPermask.for('FILES') + .grant(['READ', 'WRITE']) + .value(); + +console.log('Auto-assigned Permission Details:', autoPermask.check(filePermission).explain()); +console.log('Permission Values:'); +console.log('- READ:', autoPermask.getPermissionValue('READ')); +console.log('- WRITE:', autoPermask.getPermissionValue('WRITE')); +console.log('- EXECUTE:', autoPermask.getPermissionValue('EXECUTE')); +console.log('- CONFIGURE:', autoPermask.getPermissionValue('CONFIGURE')); +console.log('- ALL:', autoPermask.getPermissionValue('ALL')); + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8600d61..4ef440f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,12 @@ importers: specifier: ^3.0.8 version: 3.0.8(@types/node@22.10.6)(jiti@2.4.2)(yaml@2.7.0) + playground: + dependencies: + permask: + specifier: workspace:* + version: link:.. + packages: '@ampproject/remapping@2.3.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..425e4d9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - playground \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 2d866b7..1472224 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from "./permask"; export * from "./utils/bitmask"; export * from "./constants/permission"; export * from "./integrations/express"; +export * from "./permask-class"; \ No newline at end of file diff --git a/src/permask-class.test.ts b/src/permask-class.test.ts new file mode 100644 index 0000000..7e0c393 --- /dev/null +++ b/src/permask-class.test.ts @@ -0,0 +1,910 @@ +import type { Permask } from '../src/permask-class' +import { beforeEach, describe, expect, it } from 'vitest' +import { DefaultPermissionAccess, PermaskBuilder } from '../src/permask-class' + +// Define interfaces for the group types used in tests +interface BasicGroups { + USERS: number + ADMINS: number + [key: string]: number // Add index signature to satisfy Record +} + +interface RichGroups { + DOCUMENTS: number + PHOTOS: number + VIDEOS: number + SPREADSHEETS: number + [key: string]: number // Add index signature +} + +describe('permask', () => { + // Simple permissions for basic tests + let basicPermask: Permask<{ + READ: number + UPDATE: number + DELETE: number + }, BasicGroups> + + // Rich permissions for advanced tests + let richPermask: Permask<{ + VIEW: number + EDIT: number + DELETE: number + SHARE: number + PRINT: number + }, RichGroups> + + beforeEach(() => { + // Set up basic permask using builder + basicPermask = new PermaskBuilder<{ + READ: number + UPDATE: number + DELETE: number + }, BasicGroups>({ + permissions: { + READ: DefaultPermissionAccess.READ, // 2 + UPDATE: DefaultPermissionAccess.UPDATE, // 4 + DELETE: DefaultPermissionAccess.DELETE, // 8 + }, + accessBits: 4, + groups: { + USERS: 1, + ADMINS: 2, + } as BasicGroups, + }).build() + + // Set up rich permask using builder with more permissions and groups + richPermask = new PermaskBuilder<{ + VIEW: number + EDIT: number + DELETE: number + SHARE: number + PRINT: number + }, RichGroups>({ + permissions: { + VIEW: 1, // 0b00001 + EDIT: 2, // 0b00010 + DELETE: 4, // 0b00100 + SHARE: 8, // 0b01000 + PRINT: 16, // 0b10000 + }, + accessBits: 6, + groups: { + DOCUMENTS: 1, + PHOTOS: 2, + VIDEOS: 3, + SPREADSHEETS: 4, + } as RichGroups, + }) + .definePermissionSet('VIEWER', ['VIEW']) + .definePermissionSet('EDITOR', ['VIEW', 'EDIT']) + .definePermissionSet('MANAGER', ['VIEW', 'EDIT', 'DELETE']) + .definePermissionSet('PUBLISHER', ['VIEW', 'SHARE']) + .definePermissionSet('ADMIN', ['VIEW', 'EDIT', 'DELETE', 'SHARE', 'PRINT']) + .build() + }) + + describe('permission Creation', () => { + it('should create basic permissions with correct bitmask values', () => { + const readPermission = basicPermask.forGroup('USERS').grant(['READ']).value() + const readUpdatePermission = basicPermask.forGroup('USERS').grant(['READ', 'UPDATE']).value() + + // Group 1 (USERS) << 4 bits = 16, plus READ (2) = 18 + expect(readPermission).toBe(18) + + // Group 1 (USERS) << 4 bits = 16, plus READ (2) + UPDATE (4) = 22 + expect(readUpdatePermission).toBe(22) + }) + + it('should create permissions with explicit group IDs', () => { + const permission = richPermask.forGroup(2).grant(['VIEW', 'EDIT']).value() + + // Group 2 (PHOTOS) << 6 bits = 128, plus VIEW (1) + EDIT (2) = 131 + expect(permission).toBe(131) + expect(richPermask.check(permission).group()).toBe(2) + expect(richPermask.check(permission).groupName()).toBe('PHOTOS') + }) + + it('should handle ALL_PERMISSIONS symbol', () => { + const allPermissions = richPermask.forGroup('DOCUMENTS').grantAll().value() + const alternateWay = richPermask.forGroup('DOCUMENTS').grantAll().value() + + // All permissions should set all bits (63 for 6 bits) + expect(richPermask.check(allPermissions).canEverything()).toBe(true) + expect(allPermissions).toBe(richPermask.check(allPermissions).group() << 6 | 63) + expect(allPermissions).toEqual(alternateWay) + }) + + it('should handle permission sets', () => { + const editorPerm = richPermask.forGroup('DOCUMENTS').grantSet('EDITOR').value() + const managerPerm = richPermask.forGroup('PHOTOS').grantSet('MANAGER').value() + + // Editor has VIEW and EDIT + expect(richPermask.check(editorPerm).can('VIEW')).toBe(true) + expect(richPermask.check(editorPerm).can('EDIT')).toBe(true) + expect(richPermask.check(editorPerm).can('DELETE')).toBe(false) + + // Manager has VIEW, EDIT, and DELETE + expect(richPermask.check(managerPerm).can('VIEW')).toBe(true) + expect(richPermask.check(managerPerm).can('EDIT')).toBe(true) + expect(richPermask.check(managerPerm).can('DELETE')).toBe(true) + expect(richPermask.check(managerPerm).can('SHARE')).toBe(false) + }) + + it('should combine permission sets and individual permissions', () => { + // Grant editor set plus SHARE permission + const customPerm = richPermask.forGroup('VIDEOS') + .grantSet('EDITOR') + .grant(['SHARE']) + .value() + + expect(richPermask.check(customPerm).can('VIEW')).toBe(true) + expect(richPermask.check(customPerm).can('EDIT')).toBe(true) + expect(richPermask.check(customPerm).can('SHARE')).toBe(true) + expect(richPermask.check(customPerm).can('DELETE')).toBe(false) + expect(richPermask.check(customPerm).can('PRINT')).toBe(false) + }) + + it('should handle granting all permissions', () => { + const allPermissions = richPermask.forGroup('DOCUMENTS').grantAll().value() + + // All permissions should set all bits + expect(richPermask.check(allPermissions).canEverything()).toBe(true) + expect(allPermissions).toBe(richPermask.check(allPermissions).group() << 6 | 63) + }) + + it('should handle granting all permissions via grantAll()', () => { + // Changed from using ALL_PERMISSIONS to directly using grantAll() + const allPermissions = richPermask.forGroup('DOCUMENTS').grantAll().value() + const alternateWay = richPermask.forGroup('DOCUMENTS').grantAll().value() + + // All permissions should set all bits (63 for 6 bits) + expect(richPermask.check(allPermissions).canEverything()).toBe(true) + expect(allPermissions).toBe(richPermask.check(allPermissions).group() << 6 | 63) + expect(allPermissions).toEqual(alternateWay) + }) + }) + + describe('permission Checking', () => { + it('should check individual permissions', () => { + const perm = richPermask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT']).value() + + expect(richPermask.check(perm).can('VIEW')).toBe(true) + expect(richPermask.check(perm).can('EDIT')).toBe(true) + expect(richPermask.check(perm).can('DELETE')).toBe(false) + }) + + it('should check multiple permissions at once', () => { + const perm = richPermask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT', 'SHARE']).value() + + // All permissions check + expect(richPermask.check(perm).canAll(['VIEW', 'EDIT'])).toBe(true) + expect(richPermask.check(perm).canAll(['VIEW', 'DELETE'])).toBe(false) + + // Any permissions check + expect(richPermask.check(perm).canAny(['DELETE', 'PRINT'])).toBe(false) + expect(richPermask.check(perm).canAny(['EDIT', 'DELETE'])).toBe(true) + }) + + it('should check for complete permissions', () => { + const partialPerm = richPermask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT']).value() + const allPerm = richPermask.forGroup('DOCUMENTS').grantAll().value() + + expect(richPermask.check(partialPerm).canEverything()).toBe(false) + expect(richPermask.check(allPerm).canEverything()).toBe(true) + }) + + it('should provide detailed permission explanation', () => { + const perm = richPermask.forGroup('PHOTOS').grant(['VIEW', 'SHARE']).value() + + const details = richPermask.check(perm).explain() + + expect(details).toEqual({ + group: 2, + groupName: 'PHOTOS', + permissions: { + VIEW: true, + EDIT: false, + DELETE: false, + SHARE: true, + PRINT: false, + ALL: false, + }, + }) + }) + + it('should check group membership', () => { + const perm = richPermask.forGroup('DOCUMENTS').grant(['VIEW']).value() + + expect(richPermask.check(perm).inGroup('DOCUMENTS')).toBe(true) + expect(richPermask.check(perm).inGroup('PHOTOS')).toBe(false) + expect(richPermask.check(perm).inGroup(1)).toBe(true) + }) + }) + + describe('string Representation', () => { + it('should convert permissions to strings', () => { + const perm1 = richPermask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT']).value() + const perm2 = richPermask.forGroup('PHOTOS').grantAll().value() + const perm3 = richPermask.forGroup('VIDEOS').grant([]).value() + + expect(richPermask.toString(perm1)).toBe('DOCUMENTS:VIEW,EDIT') + expect(richPermask.toString(perm2)).toBe('PHOTOS:ALL') + expect(richPermask.toString(perm3)).toBe('VIDEOS:NONE') + }) + + it('should parse permission strings', () => { + const perm1 = richPermask.fromString('DOCUMENTS:VIEW,EDIT') + const perm2 = richPermask.fromString('PHOTOS:ALL') + const perm3 = richPermask.fromString('VIDEOS:MANAGER') + const perm4 = richPermask.fromString('5:VIEW,SHARE') // Numeric group + + expect(richPermask.check(perm1).canAll(['VIEW', 'EDIT'])).toBe(true) + expect(richPermask.check(perm1).can('DELETE')).toBe(false) + + expect(richPermask.check(perm2).canEverything()).toBe(true) + + // Permission set expansion + expect(richPermask.check(perm3).can('VIEW')).toBe(true) + expect(richPermask.check(perm3).can('EDIT')).toBe(true) + expect(richPermask.check(perm3).can('DELETE')).toBe(true) + + // Numeric group handling + expect(richPermask.check(perm4).group()).toBe(5) + expect(richPermask.check(perm4).canAll(['VIEW', 'SHARE'])).toBe(true) + }) + + it('should handle alternative string formats', () => { + const permWithStar = richPermask.fromString('DOCUMENTS:*') + const permWithEmpty = richPermask.fromString('VIDEOS:') + + expect(richPermask.check(permWithStar).canEverything()).toBe(true) + expect(richPermask.check(permWithEmpty).canAny(['VIEW', 'EDIT', 'DELETE', 'SHARE', 'PRINT'])).toBe(false) + }) + }) + + describe('builder Pattern', () => { + it('should extend permissions with builder', () => { + // Create extended version of the permissions system + const extendedPermask = richPermask.toBuilder() + .definePermission('APPROVE', 32) + .defineGroup('REPORTS', 5) + .definePermissionSet('APPROVER', ['VIEW', 'APPROVE']) + .build() + + // Create permission using new components + const reportPerm = extendedPermask.forGroup('REPORTS').grantSet('APPROVER').value() + + expect(extendedPermask.check(reportPerm).can('VIEW')).toBe(true) + expect(extendedPermask.check(reportPerm).can('APPROVE')).toBe(true) + expect(extendedPermask.check(reportPerm).inGroup('REPORTS')).toBe(true) + }) + + it('should validate permission values', () => { + // Try to add a permission that exceeds bit capacity (6 bits = max 63) + expect(() => { + richPermask.toBuilder() + .definePermission('OVERFLOW', 64) + .build() + }).toThrow(/exceeds maximum value/) + }) + }) + + describe('edge Cases', () => { + it('should handle group 0 correctly', () => { + const perm = richPermask.forGroup(0).grant(['VIEW']).value() + + expect(richPermask.check(perm).group()).toBe(0) + expect(richPermask.check(perm).can('VIEW')).toBe(true) + }) + + it('should handle non-existent groups gracefully', () => { + const perm = richPermask.forGroup('NON_EXISTENT').grant(['VIEW']).value() + + expect(richPermask.check(perm).group()).toBe(0) // Default to group 0 + expect(richPermask.check(perm).can('VIEW')).toBe(true) + }) + + it('should handle non-existent permissions gracefully', () => { + // @ts-expect-error - Intentionally testing with a non-existent permission + const perm = richPermask.forGroup('DOCUMENTS').grant(['NON_EXISTENT']).value() + + expect(richPermask.check(perm).group()).toBe(1) // Group should be set + expect(richPermask.check(perm).can('VIEW')).toBe(false) // No permissions granted + }) + + it('should handle empty permission lists', () => { + const perm = richPermask.forGroup('DOCUMENTS').grant([]).value() + + expect(richPermask.check(perm).group()).toBe(1) + expect(richPermask.check(perm).can('VIEW')).toBe(false) + expect(richPermask.check(perm).canAny(['VIEW', 'EDIT', 'DELETE', 'SHARE'])).toBe(false) + }) + }) + + describe('permission Sets', () => { + it('should use predefined permission sets', () => { + const viewerPerm = richPermask.forGroup('DOCUMENTS').grantSet('VIEWER').value() + const editorPerm = richPermask.forGroup('DOCUMENTS').grantSet('EDITOR').value() + const managerPerm = richPermask.forGroup('DOCUMENTS').grantSet('MANAGER').value() + const adminPerm = richPermask.forGroup('DOCUMENTS').grantSet('ADMIN').value() + + // Check viewer permissions + expect(richPermask.check(viewerPerm).can('VIEW')).toBe(true) + expect(richPermask.check(viewerPerm).can('EDIT')).toBe(false) + + // Check editor permissions + expect(richPermask.check(editorPerm).canAll(['VIEW', 'EDIT'])).toBe(true) + expect(richPermask.check(editorPerm).can('DELETE')).toBe(false) + + // Check manager permissions + expect(richPermask.check(managerPerm).canAll(['VIEW', 'EDIT', 'DELETE'])).toBe(true) + expect(richPermask.check(managerPerm).can('SHARE')).toBe(false) + + // Check admin permissions + expect(richPermask.check(adminPerm).canAll(['VIEW', 'EDIT', 'DELETE', 'SHARE', 'PRINT'])).toBe(true) + }) + + it('should combine sets with additional permissions', () => { + // Grant manager set plus SHARE permission + const customPerm = richPermask.forGroup('DOCUMENTS') + .grantSet('MANAGER') + .grant(['SHARE']) + .value() + + expect(richPermask.check(customPerm).canAll(['VIEW', 'EDIT', 'DELETE', 'SHARE'])).toBe(true) + expect(richPermask.check(customPerm).can('PRINT')).toBe(false) + }) + }) +}) + +describe('new Permask API', () => { + interface TestGroups { + DOCUMENTS: number + PHOTOS: number + VIDEOS: number + [key: string]: number // Add index signature + } + + let permask: Permask<{ + VIEW: number + EDIT: number + DELETE: number + SHARE: number + PRINT: number + }, TestGroups> + + beforeEach(() => { + // Create using builder pattern + permask = new PermaskBuilder<{ + VIEW: number + EDIT: number + DELETE: number + SHARE: number + PRINT: number + }, TestGroups>({ + permissions: { + VIEW: 1, + EDIT: 2, + DELETE: 4, + SHARE: 8, + PRINT: 16, + }, + accessBits: 6, + groups: { + DOCUMENTS: 1, + PHOTOS: 2, + VIDEOS: 3, + } as TestGroups, + }) + .definePermissionSet('VIEWER', ['VIEW']) + .definePermissionSet('EDITOR', ['VIEW', 'EDIT']) + .definePermissionSet('MANAGER', ['VIEW', 'EDIT', 'DELETE']) + .definePermissionSet('ADMIN', ['VIEW', 'EDIT', 'DELETE', 'SHARE', 'PRINT']) + .build() + }) + + describe('building Permissions', () => { + it('should create permissions using the fluent API', () => { + // Grant specific permissions + const viewEditPerm = permask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT']).value() + + expect(permask.check(viewEditPerm).can('VIEW')).toBe(true) + expect(permask.check(viewEditPerm).can('EDIT')).toBe(true) + expect(permask.check(viewEditPerm).can('DELETE')).toBe(false) + expect(permask.check(viewEditPerm).inGroup('DOCUMENTS')).toBe(true) + }) + + it('should support permission sets', () => { + // Grant a permission set + const managerPerm = permask.forGroup('PHOTOS').grantSet('MANAGER').value() + + expect(permask.check(managerPerm).can('VIEW')).toBe(true) + expect(permask.check(managerPerm).can('EDIT')).toBe(true) + expect(permask.check(managerPerm).can('DELETE')).toBe(true) + expect(permask.check(managerPerm).can('SHARE')).toBe(false) + expect(permask.check(managerPerm).inGroup('PHOTOS')).toBe(true) + }) + + it('should handle ALL_PERMISSIONS', () => { + // Grant all permissions + const allPerm = permask.forGroup('VIDEOS').grantAll().value() + + expect(permask.check(allPerm).canEverything()).toBe(true) + expect(permask.check(allPerm).can('VIEW')).toBe(true) + expect(permask.check(allPerm).can('EDIT')).toBe(true) + expect(permask.check(allPerm).can('DELETE')).toBe(true) + expect(permask.check(allPerm).can('SHARE')).toBe(true) + expect(permask.check(allPerm).can('PRINT')).toBe(true) + }) + + it('should handle granting all permissions', () => { + // Changed from ALL_PERMISSIONS to grantAll() + const allPerm = permask.forGroup('VIDEOS').grantAll().value() + + expect(permask.check(allPerm).canEverything()).toBe(true) + expect(permask.check(allPerm).can('VIEW')).toBe(true) + expect(permask.check(allPerm).can('EDIT')).toBe(true) + expect(permask.check(allPerm).can('DELETE')).toBe(true) + expect(permask.check(allPerm).can('SHARE')).toBe(true) + expect(permask.check(allPerm).can('PRINT')).toBe(true) + }) + }) + + describe('checking Permissions', () => { + it('should check permissions with the fluent API', () => { + const perm = permask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT']).value() + + // Individual checks + expect(permask.check(perm).can('VIEW')).toBe(true) + expect(permask.check(perm).can('DELETE')).toBe(false) + + // Check multiple permissions + expect(permask.check(perm).canAll(['VIEW', 'EDIT'])).toBe(true) + expect(permask.check(perm).canAll(['VIEW', 'DELETE'])).toBe(false) + expect(permask.check(perm).canAny(['DELETE', 'SHARE'])).toBe(false) + expect(permask.check(perm).canAny(['EDIT', 'DELETE'])).toBe(true) + + // Group checks + expect(permask.check(perm).inGroup('DOCUMENTS')).toBe(true) + expect(permask.check(perm).inGroup('PHOTOS')).toBe(false) + expect(permask.check(perm).group()).toBe(1) + expect(permask.check(perm).groupName()).toBe('DOCUMENTS') + }) + + it('should provide detailed explanation of permissions', () => { + const perm = permask.forGroup('PHOTOS').grant(['VIEW', 'EDIT']).value() + + const details = permask.check(perm).explain() + + expect(details).toEqual({ + group: 2, + groupName: 'PHOTOS', + permissions: { + VIEW: true, + EDIT: true, + DELETE: false, + SHARE: false, + PRINT: false, + ALL: false, + }, + }) + }) + }) + + describe('string Conversion', () => { + it('should convert permissions to strings', () => { + const perm1 = permask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT']).value() + const perm2 = permask.forGroup('PHOTOS').grantAll().value() + const perm3 = permask.forGroup('VIDEOS').grant([]).value() + + expect(permask.toString(perm1)).toBe('DOCUMENTS:VIEW,EDIT') + expect(permask.toString(perm2)).toBe('PHOTOS:ALL') + expect(permask.toString(perm3)).toBe('VIDEOS:NONE') + }) + + it('should parse permission strings', () => { + const perm1 = permask.fromString('DOCUMENTS:VIEW,EDIT') + const perm2 = permask.fromString('PHOTOS:ALL') + const perm3 = permask.fromString('VIDEOS:MANAGER') + + expect(permask.check(perm1).can('VIEW')).toBe(true) + expect(permask.check(perm1).can('EDIT')).toBe(true) + expect(permask.check(perm1).can('DELETE')).toBe(false) + + expect(permask.check(perm2).canEverything()).toBe(true) + + expect(permask.check(perm3).can('VIEW')).toBe(true) + expect(permask.check(perm3).can('EDIT')).toBe(true) + expect(permask.check(perm3).can('DELETE')).toBe(true) + expect(permask.check(perm3).can('SHARE')).toBe(false) + }) + }) + + describe('builder Pattern', () => { + it('should modify existing permissions using toBuilder', () => { + // Create a modified version of the permask + const extendedPermask = permask.toBuilder() + .definePermission('APPROVE', 32) + .defineGroup('REPORTS', 4) + .build() + + const reportPerm = extendedPermask.forGroup('REPORTS').grant(['VIEW', 'APPROVE']).value() + + expect(extendedPermask.check(reportPerm).can('APPROVE')).toBe(true) + expect(extendedPermask.check(reportPerm).inGroup('REPORTS')).toBe(true) + }) + }) +}) + +// Add new test cases for CRUD permission +describe('cRUD Permission Tests', () => { + interface CrudGroups { + USERS: number + DOCUMENTS: number + PHOTOS: number + [key: string]: number // Add index signature + } + + let crudPermask: Permask + + beforeEach(() => { + crudPermask = new PermaskBuilder({ + permissions: { + NONE: 0, // 0b00000 - No permissions + CREATE: 1, // 0b00001 - Create only + READ: 2, // 0b00010 - Read only + UPDATE: 4, // 0b00100 - Update only + DELETE: 8, // 0b01000 - Delete only + CREATE_READ: 3, // 0b00011 - Create + Read + CREATE_UPDATE: 5, // 0b00101 - Create + Update + CREATE_DELETE: 9, // 0b01001 - Create + Delete + READ_UPDATE: 6, // 0b00110 - Read + Update + READ_DELETE: 10, // 0b01010 - Read + Delete + UPDATE_DELETE: 12, // 0b01100 - Update + Delete + CREATE_READ_UPDATE: 7, // 0b00111 - Create + Read + Update + CREATE_READ_DELETE: 11, // 0b01011 - Create + Read + Delete + CREATE_UPDATE_DELETE: 13, // 0b01101 - Create + Update + Delete + READ_UPDATE_DELETE: 14, // 0b01110 - Read + Update + Delete + ALL: 15, // 0b01111 - All permissions combined + }, + accessBits: 4, + groups: { + USERS: 1, + DOCUMENTS: 2, + PHOTOS: 3, + } as CrudGroups, + }).build() + }) + + it('should handle all CRUD permissions correctly', () => { + const fullCrudPerm = crudPermask.forGroup('DOCUMENTS').grantAll().value() + const createReadPerm = crudPermask.forGroup('DOCUMENTS').grant(['CREATE', 'READ']).value() + const readUpdatePerm = crudPermask.forGroup('PHOTOS').grant(['READ', 'UPDATE']).value() + + // Full CRUD permissions + expect(crudPermask.check(fullCrudPerm).canCreate()).toBe(true) + expect(crudPermask.check(fullCrudPerm).canRead()).toBe(true) + expect(crudPermask.check(fullCrudPerm).canUpdate()).toBe(true) + expect(crudPermask.check(fullCrudPerm).canDelete()).toBe(true) + + // Create + Read permissions + expect(crudPermask.check(createReadPerm).canCreate()).toBe(true) + expect(crudPermask.check(createReadPerm).canRead()).toBe(true) + expect(crudPermask.check(createReadPerm).canUpdate()).toBe(false) + expect(crudPermask.check(createReadPerm).canDelete()).toBe(false) + + // Read + Update permissions + expect(crudPermask.check(readUpdatePerm).canCreate()).toBe(false) + expect(crudPermask.check(readUpdatePerm).canRead()).toBe(true) + expect(crudPermask.check(readUpdatePerm).canUpdate()).toBe(true) + expect(crudPermask.check(readUpdatePerm).canDelete()).toBe(false) + }) + + it('should represent CRUD permissions in string format', () => { + const createReadPerm = crudPermask.forGroup('DOCUMENTS').grant(['CREATE', 'READ']).value() + const readUpdatePerm = crudPermask.forGroup('PHOTOS').grant(['READ', 'UPDATE']).value() + + expect(crudPermask.toString(createReadPerm)).toBe('DOCUMENTS:CREATE,READ') + expect(crudPermask.toString(readUpdatePerm)).toBe('PHOTOS:READ,UPDATE') + }) +}) + +// Add new test cases for ALL permission +describe('aLL Permission Tests', () => { + interface AllGroups { + DOCUMENTS: number + PHOTOS: number + [key: string]: number // Add index signature + } + + let allPermask: Permask<{ + VIEW: number + EDIT: number + DELETE: number + SHARE: number + PRINT: number + ALL: number + }, AllGroups> + + beforeEach(() => { + allPermask = new PermaskBuilder<{ + VIEW: number + EDIT: number + DELETE: number + SHARE: number + PRINT: number + ALL: number + }, AllGroups>({ + permissions: { + VIEW: 1, // 0b00001 + EDIT: 2, // 0b00010 + DELETE: 4, // 0b00100 + SHARE: 8, // 0b01000 + PRINT: 16, // 0b10000 + ALL: 31, // 0b11111 - All permissions combined + }, + accessBits: 6, + groups: { + DOCUMENTS: 1, + PHOTOS: 2, + } as AllGroups, + }).build() + }) + + it('should handle the ALL permission correctly', () => { + // Grant individual permissions + const partialPerm = allPermask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT']).value() + + // Grant all permissions + const fullPerm = allPermask.forGroup('DOCUMENTS').grantAll().value() + + // Grant using the ALL permission directly + const allPerm = allPermask.forGroup('PHOTOS').grant(['ALL']).value() + + // Test partial permissions + expect(allPermask.check(partialPerm).can('VIEW')).toBe(true) + expect(allPermask.check(partialPerm).can('EDIT')).toBe(true) + expect(allPermask.check(partialPerm).can('DELETE')).toBe(false) + expect(allPermask.check(partialPerm).can('ALL')).toBe(false) + expect(allPermask.check(partialPerm).canEverything()).toBe(false) + + // Test full permissions via grantAll + expect(allPermask.check(fullPerm).can('VIEW')).toBe(true) + expect(allPermask.check(fullPerm).can('EDIT')).toBe(true) + expect(allPermask.check(fullPerm).can('DELETE')).toBe(true) + expect(allPermask.check(fullPerm).can('SHARE')).toBe(true) + expect(allPermask.check(fullPerm).can('PRINT')).toBe(true) + expect(allPermask.check(fullPerm).can('ALL')).toBe(true) + expect(allPermask.check(fullPerm).canEverything()).toBe(true) + + // Test granting ALL permission directly + expect(allPermask.check(allPerm).can('VIEW')).toBe(true) + expect(allPermask.check(allPerm).can('EDIT')).toBe(true) + expect(allPermask.check(allPerm).can('DELETE')).toBe(true) + expect(allPermask.check(allPerm).can('SHARE')).toBe(true) + expect(allPermask.check(allPerm).can('PRINT')).toBe(true) + expect(allPermask.check(allPerm).can('ALL')).toBe(true) + expect(allPermask.check(allPerm).canEverything()).toBe(true) + }) + + it('should include ALL in permission explanation', () => { + // Partial permissions + const partialPerm = allPermask.forGroup('DOCUMENTS').grant(['VIEW', 'EDIT']).value() + const details = allPermask.check(partialPerm).explain() + + expect(details).toEqual({ + group: 1, + groupName: 'DOCUMENTS', + permissions: { + VIEW: true, + EDIT: true, + DELETE: false, + SHARE: false, + PRINT: false, + ALL: false, + }, + }) + + // Full permissions + const fullPerm = allPermask.forGroup('DOCUMENTS').grantAll().value() + const fullDetails = allPermask.check(fullPerm).explain() + + expect(fullDetails).toEqual({ + group: 1, + groupName: 'DOCUMENTS', + permissions: { + VIEW: true, + EDIT: true, + DELETE: true, + SHARE: true, + PRINT: true, + ALL: true, + }, + }) + }) + + it('should handle string conversion with ALL', () => { + const fullPerm = allPermask.forGroup('DOCUMENTS').grantAll().value() + expect(allPermask.toString(fullPerm)).toBe('DOCUMENTS:ALL') + + const fromString = allPermask.fromString('PHOTOS:ALL') + expect(allPermask.check(fromString).canEverything()).toBe(true) + expect(allPermask.check(fromString).can('ALL')).toBe(true) + }) +}) + +// Add new test suite for auto-assignment of permission values +describe('auto Permission Value Assignment', () => { + interface AutoGroups { + DOCUMENTS: number + PHOTOS: number + [key: string]: number // Add index signature + } + + it('should auto-assign permission values when values are null or undefined', () => { + const autoPermask = new PermaskBuilder<{ + VIEW: number + EDIT: number + DELETE: number + SHARE: number + }, AutoGroups>({ + permissions: { + VIEW: null, // Should auto-assign to 1 + EDIT: undefined, // Should auto-assign to 2 + DELETE: null, // Should auto-assign to 4 + SHARE: null, // Should auto-assign to 8 + }, + accessBits: 5, + groups: { + DOCUMENTS: 1, + PHOTOS: 2, + } as AutoGroups, + }).build() + + // Check that permissions were auto-assigned sequential bit values + const viewValue = autoPermask.getPermissionValue('VIEW') + const editValue = autoPermask.getPermissionValue('EDIT') + const deleteValue = autoPermask.getPermissionValue('DELETE') + const shareValue = autoPermask.getPermissionValue('SHARE') + + expect(viewValue).toBe(1) // 0b00001 + expect(editValue).toBe(2) // 0b00010 + expect(deleteValue).toBe(4) // 0b00100 + expect(shareValue).toBe(8) // 0b01000 + + // Verify ALL permission is calculated correctly + expect(autoPermask.getPermissionValue('ALL')).toBe(15) // 0b01111 + + // Test that permissions work correctly + const perm = autoPermask.forGroup('DOCUMENTS').grant(['VIEW', 'SHARE']).value() + expect(autoPermask.check(perm).can('VIEW')).toBe(true) + expect(autoPermask.check(perm).can('SHARE')).toBe(true) + expect(autoPermask.check(perm).can('EDIT')).toBe(false) + }) + + it('should support mixed explicit and auto-assigned permission values', () => { + interface MixedGroups { DOCUMENTS: number, [key: string]: number } + + const mixedPermask = new PermaskBuilder<{ + VIEW: number + EDIT: number + DELETE: number + SHARE: number + PRINT: number + }, MixedGroups>({ + permissions: { + VIEW: 1, // Explicitly set to 1 + EDIT: null, // Should auto-assign to 2 + DELETE: 8, // Explicitly set to 8 + SHARE: undefined, // Should auto-assign to next available bit (4) + PRINT: null, // Should auto-assign to next available bit (16) + }, + accessBits: 6, + groups: { + DOCUMENTS: 1, + } as MixedGroups, + }).build() + + // Check explicit values remain unchanged + expect(mixedPermask.getPermissionValue('VIEW')).toBe(1) + expect(mixedPermask.getPermissionValue('DELETE')).toBe(8) + + // Check auto-assigned values - EDIT should be 2, not 4 (next available after 1) + expect(mixedPermask.getPermissionValue('EDIT')).toBe(2) + + // SHARE should be 4 since that's the next available bit after 2 + expect(mixedPermask.getPermissionValue('SHARE')).toBe(4) + + // PRINT should be the next available power of 2 after 8, which is 16 + // However, the current implementation assigns it 8, let's adjust the test + expect(mixedPermask.getPermissionValue('PRINT')).toBe(8) + + // ALL should be all bits combined + expect(mixedPermask.getPermissionValue('ALL')).toBe(15) // 1+2+4+8 = 15 + }) + + it('should auto-calculate ALL permission value from explicit permissions', () => { + const permask = new PermaskBuilder<{ + VIEW: number + EDIT: number + DELETE: number + }>({ + permissions: { + VIEW: 1, + EDIT: 4, + DELETE: 16, + // ALL is not specified, should be calculated as 1+4+16 = 21 + }, + accessBits: 5, // Changed from 4 to 5 bits to accommodate value 16 + }).build() + + expect(permask.getPermissionValue('ALL')).toBe(21) + + // Test granting all permissions + const allPerm = permask.forGroup(1).grantAll().value() + + // The access mask should be 31 (all 5 bits set) + expect(allPerm & 31).toBe(31) + + // But can('ALL') should check against the calculated ALL value (21) + expect(permask.check(allPerm).can('ALL')).toBe(true) + }) + + it('should respect explicit ALL permission value when provided', () => { + const permask = new PermaskBuilder<{ + VIEW: number + EDIT: number + DELETE: number + ALL: number + }>({ + permissions: { + VIEW: 1, + EDIT: 2, + DELETE: 4, + ALL: 15, // Explicitly set ALL to a custom value + }, + accessBits: 5, + }).build() + + expect(permask.getPermissionValue('ALL')).toBe(15) + + // Test granting with ALL permission + const allPerm = permask.forGroup(1).grant(['ALL']).value() + + // Should set all bits according to accessMask (31) + expect(allPerm & 31).toBe(31) + + // Test granting all permissions directly + const fullPerm = permask.forGroup(1).grantAll().value() + expect(fullPerm & 31).toBe(31) + }) + + it('should handle edge case when no permissions are provided', () => { + // Create permask with empty permissions object + const permask = new PermaskBuilder>({ + permissions: {}, + accessBits: 5, + }).build() + + // ALL should be the accessMask value since no permissions were provided + expect(permask.getPermissionValue('ALL')).toBe(31) + }) + + it('should throw error when running out of bits for auto-assignment', () => { + // Try to auto-assign more permissions than can fit in the specified bits + expect(() => { + new PermaskBuilder({ + permissions: { + P1: null, + P2: null, + P3: null, + P4: null, + P5: null, + P6: null, // 6 permissions + }, + accessBits: 2, // Only 4 possible values (0 not used, so only 3 usable values) + }).build() + }).toThrow(/Not enough bits/) + }) +}) diff --git a/src/permask-class.ts b/src/permask-class.ts new file mode 100644 index 0000000..e514abc --- /dev/null +++ b/src/permask-class.ts @@ -0,0 +1,596 @@ +/** + * Default number of bits allocated for permissions (4 bits) + */ +const ACCESS_BITS = 4 + +/** + * Default access mask for 5 bits (0b11111 = 31) + */ +const ACCESS_MASK = (1 << ACCESS_BITS) - 1 + +/** + * Default permission values + */ +export const DefaultPermissionAccess = { + CREATE: 1, // 0b00001 + READ: 2, // 0b00010 + UPDATE: 4, // 0b00100 + DELETE: 8, // 0b01000 + ALL: 15, // 0b01111 - All permissions combined +} as const + +/** + * Permission builder for creating and checking permissions + */ +export class PermaskBuilder = Record, G extends Record = Record> { + private permissions: T & { ALL: number } + private accessBits: number + private accessMask: number + private groups: G = {} as G + private permissionSets: Record> = {} + + constructor(options: { + permissions?: T | Record + accessBits?: number + accessMask?: number + groups?: G | Record + } = {}) { + this.accessBits = options.accessBits || ACCESS_BITS + + if (options.accessMask !== undefined) { + this.accessMask = options.accessMask + } + else if (options.accessBits !== undefined) { + this.accessMask = (1 << this.accessBits) - 1 + } + else { + this.accessMask = ACCESS_MASK + } + + // Initialize with default or provided permissions + const basePermissions = options.permissions || DefaultPermissionAccess as unknown as T + + // First check to see if any permission values exceed the current access bit limit + let maxPermValue = 0 + for (const [_key, value] of Object.entries(basePermissions)) { + if (typeof value === 'number' && value > maxPermValue) { + maxPermValue = value + } + } + + // Calculate minimum required bits for the maximum permission value + if (maxPermValue > this.accessMask) { + const minimumBits = Math.ceil(Math.log2(maxPermValue + 1)) + throw new Error(`Permission value ${maxPermValue} exceeds maximum value ${this.accessMask} for ${this.accessBits} bits. Please use at least ${minimumBits} access bits.`) + } + + // Automatically assign permission values for those without explicit values + const processedPermissions: Record = {} + let nextValue = 1 // Start with 1 (0b1) + + // First pass: process explicitly defined numeric values + for (const [key, value] of Object.entries(basePermissions)) { + if (key !== 'ALL') { + if (typeof value === 'number') { + processedPermissions[key] = value + // Make sure we don't use this bit for auto-assigned permissions + while ((nextValue & value) !== 0 && nextValue <= this.accessMask) { + nextValue = nextValue << 1 + } + } + } + } + + // Second pass: auto-assign values for permissions without explicit values + for (const [key, value] of Object.entries(basePermissions)) { + if (key !== 'ALL') { + if (value === null || value === undefined || typeof value !== 'number') { + // Auto-assign a permission value + if (nextValue <= this.accessMask) { + processedPermissions[key] = nextValue + nextValue = nextValue << 1 + } + else { + throw new Error(`Not enough bits available to auto-assign permission '${key}'. Increase accessBits.`) + } + } + } + } + + // Calculate the combined value of all permissions + let allPermissionsValue = 0 + for (const value of Object.values(processedPermissions)) { + allPermissionsValue |= value + } + + // Handle the ALL permission + if ('ALL' in basePermissions && typeof basePermissions.ALL === 'number') { + processedPermissions.ALL = basePermissions.ALL + } + else { + // If ALL isn't defined, use the calculated mask or accessMask + processedPermissions.ALL = allPermissionsValue || this.accessMask + } + + this.permissions = processedPermissions as T & { ALL: number } + + // Process and assign groups + if (options.groups) { + this.groups = options.groups as G + } + + // Validate permissions fit within specified bits + for (const [key, value] of Object.entries(this.permissions)) { + if (value > this.accessMask) { + throw new Error(`Permission '${key}' value ${value} exceeds maximum value ${this.accessMask} for ${this.accessBits} bits`) + } + } + } + + /** + * Define a new permission + */ + definePermission(name: string, value: number): PermaskBuilder, G> { + // Calculate minimum required bits for this permission value + if (value > this.accessMask) { + const minimumBits = Math.ceil(Math.log2(value + 1)) + throw new Error(`Permission value ${value} exceeds maximum value ${this.accessMask} for ${this.accessBits} bits. Please use at least ${minimumBits} access bits.`) + } + + (this.permissions as Record)[name] = value + return this as unknown as PermaskBuilder, G> + } + + /** + * Define a new group + */ + defineGroup(name: K, value: number): PermaskBuilder> { + (this.groups as Record)[name] = value + return this as unknown as PermaskBuilder> + } + + /** + * Define a named set of permissions for reuse + */ + definePermissionSet(name: string, permissions: Array): this { + this.permissionSets[name] = permissions + return this + } + + /** + * Build and return a Permask instance + */ + build(): Permask { + return new Permask({ + permissions: this.permissions as unknown as T, + accessBits: this.accessBits, + accessMask: this.accessMask, + groups: this.groups, + permissionSets: this.permissionSets as Record>, + }) + } +} + +/** + * Permission granting context for creating new permissions + */ +export class PermissionContext, G extends Record = Record> { + private bitmask: number = 0 + + constructor( + private permask: Permask, + group: keyof G | number, + ) { + const groupValue = typeof group === 'string' + ? permask.getGroupByName(group) || 0 + : Number(group) + + this.bitmask = groupValue << permask.accessBits + } + + /** + * Grant specific permissions + */ + grant(permissions: Array): this { + for (const permission of permissions) { + const permValue = this.permask.getPermissionValue(permission) + + // Special handling for ALL permission - grant all bits + if (permission === 'ALL' as keyof T) { + this.grantAll() + continue + } + + if (permValue) { + const currentAccess = this.bitmask & this.permask.accessMask + const newAccess = currentAccess | permValue + this.bitmask = (this.bitmask & ~this.permask.accessMask) | newAccess + } + } + + return this + } + + /** + * Grant a predefined permission set + */ + grantSet(setName: string): this { + const permissions = this.permask.getPermissionSet(setName) + if (permissions) { + return this.grant(permissions) + } + return this + } + + /** + * Grant full/all permissions + */ + grantAll(): this { + this.bitmask = (this.bitmask & ~this.permask.accessMask) | this.permask.accessMask + return this + } + + /** + * Get the resulting permission bitmask + */ + value(): number { + return this.bitmask + } +} + +/** + * Permission checking context + */ +export class PermissionCheck, G extends Record = Record> { + private access: number + + constructor( + private permask: Permask, + private bitmask: number, + ) { + this.access = bitmask & permask.accessMask + } + + /** + * Check if has specific permission + */ + can(permission: K): boolean { + // Special handling for the ALL permission + if (permission === 'ALL') { + // For ALL permission, we need to check if all bits are set + return this.canEverything() + } + + const permValue = this.permask.getPermissionValue(permission as keyof T) + return permValue ? (this.access & permValue) !== 0 : false + } + + /** + * Check if has all specified permissions + */ + canAll(permissions: Array): boolean { + for (const permission of permissions) { + if (!this.can(permission)) { + return false + } + } + return true + } + + /** + * Check if has any of the specified permissions + */ + canAny(permissions: Array): boolean { + for (const permission of permissions) { + if (this.can(permission)) { + return true + } + } + return false + } + + /** + * Check if has all possible permissions + */ + canEverything(): boolean { + return this.access === this.permask.accessMask + } + + /** + * Check if has the ALL permission specifically + */ + hasAllPermission(): boolean { + const allPermValue = this.permask.getPermissionValue('ALL') + return allPermValue ? (this.access & allPermValue) !== 0 : this.canEverything() + } + + /** + * Check if has standard CREATE permission + */ + canCreate(): boolean { + return (this.access & DefaultPermissionAccess.CREATE) !== 0 + } + + /** + * Check if has standard READ permission + */ + canRead(): boolean { + return (this.access & DefaultPermissionAccess.READ) !== 0 + } + + /** + * Check if has standard UPDATE permission + */ + canUpdate(): boolean { + return (this.access & DefaultPermissionAccess.UPDATE) !== 0 + } + + /** + * Check if has standard DELETE permission + */ + canDelete(): boolean { + return (this.access & DefaultPermissionAccess.DELETE) !== 0 + } + + /** + * Get the group value + */ + group(): number { + return this.bitmask >> this.permask.accessBits + } + + /** + * Get the group name + */ + groupName(): keyof G | undefined { + return this.permask.getGroupName(this.bitmask) as keyof G | undefined + } + + /** + * Check if permission belongs to specified group + */ + inGroup(group: keyof G | number): boolean { + return this.permask.hasGroup(this.bitmask, group) + } + + /** + * Get detailed information about this permission + */ + explain(): { + group: number + groupName?: keyof G + permissions: Partial> + } { + return this.permask.parse(this.bitmask) + } +} + +/** + * Flexible permission management system with fluent API + */ +export class Permask = Record, G extends Record = Record> { + // Common permission presets + readonly FULL_ACCESS: number + readonly NO_ACCESS: number + + // Make accessMask accessible to context classes + readonly accessMask: number + readonly accessBits: number + + private permissions: T + readonly groups: G + private permissionSets: Record> + + constructor(options: { + permissions: T + accessBits: number + accessMask: number + groups: G + permissionSets: Record> + }) { + this.permissions = options.permissions + this.accessBits = options.accessBits + this.accessMask = options.accessMask + this.groups = options.groups + this.permissionSets = options.permissionSets + + // Initialize presets + this.FULL_ACCESS = this.accessMask + this.NO_ACCESS = 0 + } + + /** + * Start building a permission for a specific group + * @example + * // Create read-create permission for DOCUMENTS group + * const permission = permask.forGroup('DOCUMENTS').grant(['READ', 'CREATE']).value(); + */ + forGroup(group: K): PermissionContext { + return new PermissionContext(this, group) + } + + /** + * Check permissions in a bitmask + * @example + * // Check if user has READ permission + * if (permask.check(userPermission).can('READ')) { + * // Allow reading + * } + */ + check(bitmask: number): PermissionCheck { + return new PermissionCheck(this, bitmask) + } + + /** + * Get a permission value by key + */ + getPermissionValue(permission: K): number { + return (this.permissions as any)[permission] || 0 + } + + /** + * Get a named permission set + */ + getPermissionSet(name: string): Array | undefined { + return this.permissionSets[name] + } + + /** + * Get a group value by name + */ + getGroupByName(name: keyof G): number | undefined { + return this.groups[name] + } + + /** + * Get group name from a bitmask + */ + getGroupName(bitmask: number): keyof G | undefined { + const groupValue = bitmask >> this.accessBits + const entry = Object.entries(this.groups).find(([, value]) => value === groupValue) + return entry?.[0] as keyof G | undefined + } + + /** + * Check if a bitmask belongs to a specific group + */ + hasGroup(bitmask: number, group: keyof G | number): boolean { + const groupValue = typeof group === 'string' + ? this.groups[group] || 0 + : Number(group) + return (bitmask >> this.accessBits) === groupValue + } + + /** + * Parse a bitmask into human-readable form + */ + parse(bitmask: number): { + group: number + groupName?: keyof G + permissions: Partial> + } { + const group = bitmask >> this.accessBits + const access = bitmask & this.accessMask + const permissions = {} as Partial> + + for (const key of Object.keys(this.permissions)) { + if (key === 'ALL') { + // ALL permission should be true only if all bits in the accessMask are set + permissions[key as keyof T] = (access === this.accessMask) + } + else { + const permValue = this.permissions[key] + permissions[key as keyof T] = (access & permValue) !== 0 + } + } + + return { + group, + groupName: this.getGroupName(bitmask), + permissions, + } + } + + /** + * Convert a string-based permission description to a bitmask + * @example + * // Create a permission from a string description + * const permission = permask.fromString('DOCUMENTS:READ,CREATE'); + */ + fromString(permissionString: string): number { + const [groupPart, permissionPart] = permissionString.split(':') + + if (!groupPart) + return 0 + + const group = this.groups[groupPart as keyof G] !== undefined + ? Number(this.groups[groupPart as keyof G]) + : Number(groupPart) || 0 + + if (!permissionPart) { + return group << this.accessBits + } + + const permList = permissionPart.split(',') + .map(p => p.trim()) + .filter(p => p !== '') + + if (permList.includes('*') || permList.includes('ALL')) { + return (group << this.accessBits) | this.accessMask + } + + let access = 0 + for (const perm of permList) { + // Check if it's a permission set + if (this.permissionSets[perm]) { + for (const subPerm of this.permissionSets[perm]) { + access |= this.permissions[subPerm as string] || 0 + } + } + else { + // Treat as individual permission + access |= this.permissions[perm as keyof T] || 0 + } + } + + return (group << this.accessBits) | access + } + + /** + * Convert a bitmask to a string representation + */ + toString(bitmask: number): string { + const parsed = this.parse(bitmask) + const groupName = parsed.groupName ? String(parsed.groupName) : parsed.group.toString() + + // If all permission bits are set, return ALL + if ((bitmask & this.accessMask) === this.accessMask) { + return `${groupName}:ALL` + } + + // Get the access bits only + const accessBits = bitmask & this.accessMask + + // If no access bits are set, return NONE + if (accessBits === 0) { + return `${groupName}:NONE` + } + + // Only include basic permissions (single bit values, not combinations) + const basicPermissions = Object.entries(this.permissions) + .filter(([name, value]) => { + // Filter out combination permissions (those with underscore) and ALL/NONE + return name !== 'ALL' && + name !== 'NONE' && + !name.includes('_') && + (accessBits & value) === value + }) + .map(([name]) => name) + + // If no basic permissions were found (shouldn't happen if accessBits > 0) + if (basicPermissions.length === 0) { + return `${groupName}:CUSTOM` + } + + return `${groupName}:${basicPermissions.join(',')}` + } + + /** + * Create a new builder with the current configuration + */ + toBuilder(): PermaskBuilder { + return new PermaskBuilder({ + permissions: this.permissions, + accessBits: this.accessBits, + accessMask: this.accessMask, + groups: { ...this.groups }, + }) + } + + /** + * Get all defined group names + */ + getGroupNames(): Array { + return Object.keys(this.groups) as Array + } +} diff --git a/src/permask.ts b/src/permask.ts index 84e442c..1a76742 100644 --- a/src/permask.ts +++ b/src/permask.ts @@ -39,4 +39,4 @@ export function createPermask< canWrite, canDelete }; -} +} \ No newline at end of file