348 lines
11 KiB
Markdown
348 lines
11 KiB
Markdown
# @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<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:
|
|
|
|
```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:<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
|
|
|
|
```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
|
|
```
|