mono/packages/acl
2026-03-21 17:38:42 +01:00
..
dist-in filebrowser 2026-03-08 02:08:55 +01:00
docs ACL v1 - another one less:) 2026-03-21 17:38:42 +01:00
src nsis dist 2026-03-14 19:14:51 +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 v1 - another one less:) 2026-03-21 17:38:42 +01:00
package.json ACL v1 - another one less:) 2026-03-21 17:38:42 +01:00
README.md ACL v1 - another one less:) 2026-03-21 17:38:42 +01:00
tsconfig.json even more ui shit 2026-02-19 19:47:24 +01:00

@polymech/acl

npm version TypeScript

Role-based access control (RBAC) library inspired by Zend_ACL.
Source Repository: polymech/mono | Used in Filebrowser at https://service.polymech.info/.

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)
├── vfs/                    # Virtual filesystem extensions
│   ├── AclVfsClient.ts     # Guarded VFS bindings
│   ├── DecoratedVfsClient.ts # Extensible client wrapper
│   └── vfs-acl.ts          # VFS permission settings loader
└── 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
│   ├── edge-cases/                    # Edge case fixtures
│   │   └── vfs-settings.json
│   └── anon-test/                     # Anonymous access fixtures
│       └── vfs-settings.json
├── 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
├── vfs-acl-edge.e2e.test.ts           # 18 edge case tests  
├── vfs-acl-decorated.e2e.test.ts      # 15 decorated VFS tests
└── vfs-acl-anonymous.e2e.test.ts      # 8 anonymous access 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 (175 tests)
npm run lint         # ESLint

References

1. Virtual Filesystems and OS Integration

2. Browser and PWA Primitives

3. Decentralized Storage, Sync, and Collaboration

4. LocalFirst Data Structures and Collaboration Engines

5. Identity and CapabilityBased Access


This wonderful package has been brought to you by the people who get shit done :)