mono/packages/acl
2026-02-21 00:11:17 +01:00
..
dist-in acl ole :) 2026-02-21 00:11:17 +01:00
docs acl:groups, tests, async, lint 2026-02-17 23:33:57 +01:00
src acl ole :) 2026-02-21 00:11:17 +01:00
tests acl ole :) 2026-02-21 00:11:17 +01:00
.gitignore acl:groups, tests, async, lint 2026-02-17 23:33:57 +01:00
eslint.config.js acl:groups, tests, async, lint 2026-02-17 23:33:57 +01:00
package-lock.json acl:groups, tests, async, lint 2026-02-17 23:33:57 +01:00
package.json acl:groups, tests, async, lint 2026-02-17 23:33:57 +01:00
README.md acl:groups, tests, async, lint 2026-02-17 23:33:57 +01:00
tsconfig.json even more ui shit 2026-02-19 19:47:24 +01:00

@polymech/acl

Role-based access control (RBAC) library inspired by Zend_ACL.
Pure ESM, async/await, zero runtime dependencies (except pino for optional logging).

Supabase integration? See Supabase RLS Mirror — VFS Guide
Pure Postgres? See Postgres RLS — VFS Guide

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

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.

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:

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

// 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

// 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 extends MemoryBackend with JSON persistence:

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 logger as the second constructor argument:

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 interface to plug in any storage:

import type { IBackend, Value, Values } from '@polymech/acl';

class RedisBackend implements IBackend<RedisTx> {
  begin(): RedisTx { /* ... */ }
  async end(tx: RedisTx): Promise<void> { /* ... */ }
  async clean(): Promise<void> { /* ... */ }
  async get(bucket: string, key: Value): Promise<string[]> { /* ... */ }
  async union(bucket: string, keys: Value[]): Promise<string[]> { /* ... */ }
  async unions(buckets: string[], keys: Value[]): Promise<Record<string, string[]>> { /* ... */ }
  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:

{
  "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:<ownerId>:/shared/reports/q1.pdf   ← most specific
vfs:<ownerId>:/shared/reports
vfs:<ownerId>:/shared                  ← team group matches here ✓
vfs:<ownerId>:/

Access is granted if any level allows the operation.

Setup Flow

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

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

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