# @polymech/acl Role-based access control (RBAC) library inspired by Zend_ACL. Pure ESM, `async/await`, zero runtime dependencies (except [pino](https://github.com/pinojs/pino) for optional logging). > **Supabase integration?** See [Supabase RLS Mirror — VFS Guide](docs/supabase-rls-vfs.md) > **Pure Postgres?** See [Postgres RLS — VFS Guide](docs/postgres-rls-vfs.md) ## Source Layout ``` src/ ├── Acl.ts # Core ACL class ├── interfaces.ts # Type definitions & backend interface ├── index.ts # Barrel export ├── data/ │ ├── MemoryBackend.ts # In-memory backend (default) │ └── FileBackend.ts # JSON file backend (dev/testing) └── acl.test.ts # Functional tests (vitest) ``` ## Concepts | Term | Description | |------|-------------| | **User** | An identifier (string or number) representing a subject | | **Role** | A named group of permissions assigned to users | | **Resource** | Something being protected (e.g. `"posts"`, `"settings"`) | | **Permission** | An action on a resource (e.g. `"read"`, `"write"`, `"delete"`) | | **Parent** | Roles can inherit permissions from parent roles | Wildcard `"*"` grants all permissions on a resource. ## Quick Start ```ts import { Acl, MemoryBackend } from '@polymech/acl'; const acl = new Acl(new MemoryBackend()); // Define permissions await acl.allow('viewer', 'posts', 'read'); await acl.allow('editor', 'posts', ['read', 'write', 'delete']); await acl.allow('admin', 'settings', '*'); // Assign roles to users await acl.addUserRoles('alice', 'editor'); await acl.addUserRoles('bob', 'viewer'); // Check access await acl.isAllowed('alice', 'posts', 'write'); // true await acl.isAllowed('bob', 'posts', 'write'); // false await acl.isAllowed('bob', 'posts', 'read'); // true ``` ## Role Hierarchy Roles can inherit from parents. A child role gets all permissions of its ancestors. ```ts await acl.allow('viewer', 'docs', 'read'); await acl.allow('editor', 'docs', 'write'); await acl.allow('admin', 'docs', 'admin'); // editor inherits from viewer, admin inherits from editor await acl.addRoleParents('editor', 'viewer'); await acl.addRoleParents('admin', 'editor'); await acl.addUserRoles('carol', 'admin'); await acl.isAllowed('carol', 'docs', 'read'); // true (from viewer) await acl.isAllowed('carol', 'docs', 'write'); // true (from editor) await acl.isAllowed('carol', 'docs', 'admin'); // true (own) ``` ## Batch Grants Use the array syntax to define complex permission sets at once: ```ts await acl.allow([ { roles: 'moderator', allows: [ { resources: 'posts', permissions: ['read', 'edit', 'flag'] }, { resources: 'comments', permissions: ['read', 'delete'] }, ], }, { roles: 'author', allows: [ { resources: 'posts', permissions: ['read', 'create'] }, ], }, ]); ``` ## Querying Permissions ```ts // All permissions a user has on given resources const perms = await acl.allowedPermissions('alice', ['posts', 'settings']); // → { posts: ['read', 'write', 'delete'], settings: [] } // Which resources does a role have access to? const resources = await acl.whatResources('editor'); // → { posts: ['read', 'write', 'delete'] } // Which resources grant a specific permission? const writable = await acl.whatResources('editor', 'write'); // → ['posts'] ``` ## Removal ```ts // Remove specific permissions await acl.removeAllow('editor', 'posts', 'delete'); // Remove all permissions for a resource from a role await acl.removeAllow('editor', 'posts'); // Remove a role entirely await acl.removeRole('editor'); // Remove a resource from all roles await acl.removeResource('posts'); // Remove a role from a user await acl.removeUserRoles('alice', 'editor'); ``` ## File Backend (Dev/Testing) [`FileBackend`](src/data/FileBackend.ts) extends [`MemoryBackend`](src/data/MemoryBackend.ts) with JSON persistence: ```ts import { Acl, FileBackend } from '@polymech/acl'; const backend = new FileBackend('./acl-data.json'); backend.read(); // load from disk const acl = new Acl(backend); await acl.allow('admin', 'all', '*'); await acl.addUserRoles('root', 'admin'); backend.write(); // persist to disk ``` ## Logging Pass a [pino](https://github.com/pinojs/pino) logger as the second constructor argument: ```ts import pino from 'pino'; import { Acl, MemoryBackend } from '@polymech/acl'; const logger = pino({ level: 'debug' }); const acl = new Acl(new MemoryBackend(), logger); await acl.allow('admin', 'posts', '*'); // logs: { roles: ['admin'], resources: ['posts'], permissions: ['*'] } allow ``` ## Custom Backend Implement the [`IBackend`](src/interfaces.ts) interface to plug in any storage: ```ts import type { IBackend, Value, Values } from '@polymech/acl'; class RedisBackend implements IBackend { begin(): RedisTx { /* ... */ } async end(tx: RedisTx): Promise { /* ... */ } async clean(): Promise { /* ... */ } async get(bucket: string, key: Value): Promise { /* ... */ } async union(bucket: string, keys: Value[]): Promise { /* ... */ } async unions(buckets: string[], keys: Value[]): Promise> { /* ... */ } add(tx: RedisTx, bucket: string, key: Value, values: Values): void { /* ... */ } del(tx: RedisTx, bucket: string, keys: Values): void { /* ... */ } remove(tx: RedisTx, bucket: string, key: Value, values: Values): void { /* ... */ } } ``` ## Real-World Example — VFS Per-User Folder Permissions Fine-grained, path-scoped access control for a virtual filesystem where each user owns a folder and can grant specific permissions to others — individually or via **groups**. ### How It Works Each user's VFS folder contains a `vfs-settings.json` that declares who can do what: ```json { "owner": "3bb4cfbf-318b-44d3-a9d3-35680e738421", "groups": [ { "name": "team", "members": ["aaaaaaaa-...", "cccccccc-..."] }, { "name": "viewers", "members": ["dddddddd-...", "ffffffff-..."] } ], "acl": [ { "group": "team", "path": "/shared", "permissions": ["read", "write", "list", "mkdir", "delete"] }, { "group": "team", "path": "/docs", "permissions": ["read", "list"] }, { "group": "viewers", "path": "/docs", "permissions": ["read", "list"] }, { "userId": "ffffffff-...", "path": "/shared", "permissions": ["read", "list"] } ] } ``` - **Groups** bundle users — grant once, apply to all members. - **Direct user grants** coexist with groups for individual overrides. - **Path scoping** — grants apply to a specific folder and its children (`/shared`, `/docs`). - The **owner** always gets `*` on `/` (entire tree). ### Permission Model | Permission | Operations | |------------|------------| | `read` | `stat`, `readfile`, `exists` | | `list` | `readdir` | | `write` | `writefile`, `mkfile` | | `mkdir` | `mkdir` | | `delete` | `rmfile`, `rmdir` | | `rename` | `rename` | | `copy` | `copy` | | `*` | All of the above — auto-granted to the folder owner | ### Path-Scoped Resolution When a user accesses `/shared/reports/q1.pdf`, the guard walks the **resource chain** upward: ``` vfs::/shared/reports/q1.pdf ← most specific vfs::/shared/reports vfs::/shared ← team group matches here ✓ vfs::/ ``` Access is granted if **any** level allows the operation. ### Setup Flow ```mermaid sequenceDiagram participant App participant Bridge as VFS ACL Bridge participant ACL as Acl Instance App->>Bridge: loadVfsSettings acl, userDir Bridge->>Bridge: Read vfs-settings.json Bridge->>ACL: allow owner role, resource, wildcard Bridge->>ACL: addUserRoles owner, owner role loop Each group ACL entry Bridge->>ACL: allow group role, resource, permissions loop Each group member Bridge->>ACL: addUserRoles member, group role end end loop Each direct ACL entry Bridge->>ACL: allow grant role, resource, permissions Bridge->>ACL: addUserRoles grantee, grant role end Bridge-->>App: Return settings ``` ### Code Example ```ts import { Acl, MemoryBackend, AclVfsClient, loadVfsSettings } from '@polymech/acl'; // 1. Create ACL and load settings from the user's folder const acl = new Acl(new MemoryBackend()); const userDir = './data/vfs/3bb4cfbf-318b-44d3-a9d3-35680e738421'; await loadVfsSettings(acl, userDir); // 2. Create a guarded client for a specific caller const ownerId = '3bb4cfbf-318b-44d3-a9d3-35680e738421'; const callerId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; // team member const client = new AclVfsClient(acl, ownerId, callerId, { root: userDir, }); // 3. Allowed — team has read+list on /docs const files = await client.readdir('docs'); // ✓ works const exists = await client.exists('docs/readme.txt'); // ✓ works // 4. Allowed — team has full access on /shared await client.writefile('shared/notes.txt', 'hello'); // ✓ works await client.mkdir('shared/reports'); // ✓ works // 5. Denied — team has no access on /private await client.readdir('private'); // ✗ throws EACCES await client.writefile('private/x.txt', '...'); // ✗ throws EACCES // 6. Denied — team has read-only on /docs, no write await client.writefile('docs/hack.txt', '...'); // ✗ throws EACCES ``` ### Test Fixtures ``` tests/ ├── vfs/root/ │ ├── 3bb4cfbf-…/ │ │ ├── vfs-settings.json # Path-scoped direct grants │ │ ├── docs/readme.txt │ │ ├── shared/data.txt │ │ └── private/secret.txt │ └── groups-test/ │ └── vfs-settings.json # Group grants + mixed direct ├── vfs-acl.e2e.test.ts # 26 permission boundary tests ├── vfs-acl-fs.e2e.test.ts # 24 real filesystem tests ├── vfs-acl-paths.e2e.test.ts # 34 per-path nested tests └── vfs-acl-groups.e2e.test.ts # 27 groups e2e tests ``` ## Scripts ```bash npm run build # Compile TypeScript npm run dev # Watch mode npm run test:core # Core ACL tests (23) npm run test:all # Full suite (134 tests) npm run lint # ESLint ```