This commit is contained in:
lovebird 2026-02-17 22:32:32 +01:00
parent 3fc0893e0c
commit 952d125f48
24 changed files with 3753 additions and 0 deletions

337
packages/acl/README.md Normal file
View File

@ -0,0 +1,337 @@
# @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
This example demonstrates fine-grained access control for a virtual filesystem where each user owns a folder and can grant specific permissions to others.
### 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",
"acl": [
{
"userId": "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb",
"permissions": ["read", "list"]
},
{
"userId": "cccccccc-4444-5555-6666-dddddddddddd",
"permissions": ["read", "write", "list", "mkdir", "delete"]
}
]
}
```
The [VFS ACL bridge](src/vfs/vfs-acl.ts) loads these settings and translates them into ACL roles and grants. The [AclVfsClient](src/vfs/AclVfsClient.ts) then wraps `LocalVFS` and enforces permissions before every file operation.
### 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 |
### Setup Flow
When `loadVfsSettings` reads a user's settings file, it creates roles and grants in the ACL:
```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 ACL entry
Bridge->>ACL: allow grant role, resource, permissions
Bridge->>ACL: addUserRoles grantee, grant role
end
Bridge-->>App: Return settings
```
### Allowed Request — Read-Only User Lists a Directory
```mermaid
sequenceDiagram
participant User as Read-Only User
participant Client as AclVfsClient
participant ACL as Acl Instance
participant FS as LocalVFS
User->>Client: readdir "."
Client->>ACL: isAllowed userId, vfs resource, "list"
ACL-->>Client: true
Client->>FS: readdir "."
FS-->>Client: File entries
Client-->>User: File entries
```
### Denied Request — Read-Only User Tries to Write
```mermaid
sequenceDiagram
participant User as Read-Only User
participant Client as AclVfsClient
participant ACL as Acl Instance
User->>Client: writefile "secret.txt", content
Client->>ACL: isAllowed userId, vfs resource, "write"
ACL-->>Client: false
Client-->>User: EACCES - lacks "write" permission
Note right of User: LocalVFS is never reached
```
### Code Example
```ts
import { Acl, MemoryBackend } from '@polymech/acl';
import { loadVfsSettings } from '@polymech/acl/vfs/vfs-acl';
import { AclVfsClient } from '@polymech/acl/vfs/AclVfsClient';
// 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'; // read-only
const client = new AclVfsClient(acl, ownerId, callerId, {
root: userDir,
});
// 3. Allowed — caller has "list" permission
const files = await client.readdir('.'); // ✓ works
// 4. Allowed — caller has "read" permission
const exists = await client.exists('readme.txt'); // ✓ works
// 5. Denied — caller lacks "write" permission
await client.writefile('hack.txt', '...'); // ✗ throws EACCES
// 6. Denied — caller lacks "mkdir" permission
await client.mkdir('new-folder'); // ✗ throws EACCES
```
### Test Fixtures
```
tests/
├── config/
│ └── vfs.json # Mount configuration
├── vfs/
│ └── root/
│ └── 3bb4cfbf-…/
│ └── vfs-settings.json # Per-user ACL settings
├── vfs-acl.e2e.test.ts # 24 permission boundary tests
└── vfs-acl-fs.e2e.test.ts # 20 real filesystem tests
```
Run the full test suite:
```bash
npx vitest run tests/ src/acl.test.ts # 67 tests total
```
## Scripts
```bash
npm run build # Compile TypeScript
npm run dev # Watch mode
npm run test:core # Run functional tests
npm run lint # ESLint
```

View File

@ -0,0 +1,412 @@
# Pure Postgres RLS — VFS ACL Integration Guide
Adopt `@polymech/acl` as the **application-layer mirror** of native Postgres Row Level Security for your VFS.
No Supabase SDK required — just `pg` (or any Postgres client) and standard RLS policies.
## Architecture
```mermaid
flowchart LR
subgraph Postgres
RLS["RLS Policies"]
Table["vfs_permissions"]
end
subgraph Application
Pool["pg Pool"]
ACL["Acl Instance"]
Client["AclVfsClient"]
FS["LocalVFS"]
end
Pool -->|query| Table
RLS -.->|enforces| Table
Table -->|rows| ACL
ACL -->|guard| Client
Client -->|delegated ops| FS
```
Postgres RLS is the **authoritative source** for who can access what.
The `Acl` instance is a fast in-memory cache of those rules, used to guard filesystem operations without hitting the database on every file I/O.
---
## Step 1 — Schema and RLS Policies
```sql
-- Enable pgcrypto for gen_random_uuid
create extension if not exists pgcrypto;
-- Users table (skip if you already have one)
create table if not exists public.users (
id uuid primary key default gen_random_uuid(),
email text unique not null
);
-- VFS permission grants (per resource path)
create table public.vfs_permissions (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.users(id) on delete cascade,
grantee_id uuid not null references public.users(id) on delete cascade,
resource_path text not null default '/',
permissions text[] not null default '{}',
created_at timestamptz not null default now(),
unique (owner_id, grantee_id, resource_path)
);
-- resource_path examples:
-- '/' → entire user folder (root)
-- '/docs' → only the docs subfolder
-- '/photos/pub' → only photos/pub and its children
alter table public.vfs_permissions enable row level security;
```
### RLS Policies
Postgres needs to know **who is asking**. Set the current user ID per-connection via a session variable:
```sql
-- Set at the start of every connection / transaction
set local app.current_user_id = '<user-uuid>';
```
Then define policies that reference it:
```sql
-- Owners can do anything with their own grants
create policy "owner_full_access"
on public.vfs_permissions
for all
using (owner_id = current_setting('app.current_user_id')::uuid)
with check (owner_id = current_setting('app.current_user_id')::uuid);
-- Grantees can see their own grants (read-only)
create policy "grantee_read_own"
on public.vfs_permissions
for select
using (grantee_id = current_setting('app.current_user_id')::uuid);
```
### Valid Permissions
| Permission | VFS Operation |
|-----------|---------------|
| `read` | `stat`, `readfile`, `exists` |
| `list` | `readdir` |
| `write` | `writefile`, `mkfile` |
| `mkdir` | `mkdir` |
| `delete` | `rmfile`, `rmdir` |
| `rename` | `rename` |
| `copy` | `copy` |
---
## Step 2 — Postgres ACL Loader
Load grants from Postgres into the ACL. Uses a **service connection** (bypasses RLS) to fetch all grants at boot, or a **user-scoped connection** for per-request loading.
### Service-Level Loader (Boot)
```ts
import { Pool } from 'pg';
import { Acl, MemoryBackend } from '@polymech/acl';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
interface VfsRow {
owner_id: string;
grantee_id: string;
resource_path: string;
permissions: string[];
}
/**
* Load all VFS grants into an Acl instance.
* Uses a service-role connection (no RLS filtering).
*/
export async function loadVfsAclFromPostgres(acl: Acl): Promise<void> {
const { rows } = await pool.query<VfsRow>(
'SELECT owner_id, grantee_id, resource_path, permissions FROM public.vfs_permissions',
);
// Group by owner
const byOwner = new Map<string, VfsRow[]>();
for (const row of rows) {
const list = byOwner.get(row.owner_id) ?? [];
list.push(row);
byOwner.set(row.owner_id, list);
}
for (const [ownerId, grants] of byOwner) {
// Owner gets wildcard on their entire tree
const ownerResource = `vfs:${ownerId}:/`;
const ownerRole = `owner:${ownerId}`;
await acl.allow(ownerRole, ownerResource, '*');
await acl.addUserRoles(ownerId, ownerRole);
// Each grantee gets permissions scoped to a specific path
for (const grant of grants) {
// Resource = vfs:<ownerId>:<path> e.g. vfs:aaa-...:/docs
const resource = `vfs:${ownerId}:${grant.resource_path}`;
const role = `vfs-grant:${ownerId}:${grant.grantee_id}:${grant.resource_path}`;
await acl.allow(role, resource, grant.permissions);
await acl.addUserRoles(grant.grantee_id, role);
}
}
}
```
### User-Scoped Loader (Per-Request, RLS-Enforced)
When you want RLS to filter results naturally:
```ts
/**
* Load grants visible to a specific user (respects RLS).
*/
export async function loadUserVisibleGrants(
acl: Acl,
userId: string,
): Promise<void> {
const client = await pool.connect();
try {
// Set RLS context
await client.query(`set local app.current_user_id = $1`, [userId]);
const { rows } = await client.query<VfsRow>(
'SELECT owner_id, grantee_id, permissions FROM public.vfs_permissions',
);
for (const row of rows) {
const resource = `vfs:${row.owner_id}`;
const role = `vfs-grant:${row.owner_id}:${row.grantee_id}`;
await acl.allow(role, resource, row.permissions);
await acl.addUserRoles(row.grantee_id, role);
}
} finally {
client.release();
}
}
```
---
## Step 3 — Server Integration
```ts
import express from 'express';
import { Acl, MemoryBackend } from '@polymech/acl';
import { AclVfsClient } from '@polymech/acl/vfs/AclVfsClient';
import { loadVfsAclFromPostgres } from './pg-acl-loader.js';
const app = express();
// Boot: populate ACL from Postgres
const acl = new Acl(new MemoryBackend());
await loadVfsAclFromPostgres(acl);
// Middleware: extract userId from session / JWT / cookie
app.use((req, _res, next) => {
req.userId = extractUserId(req); // your auth logic
next();
});
// Route: list files in another user's folder
app.get('/vfs/:ownerId/*', async (req, res) => {
const { ownerId } = req.params;
const subpath = req.params[0] ?? '';
const client = new AclVfsClient(acl, ownerId, req.userId, {
root: `/data/vfs/${ownerId}`,
});
try {
const entries = await client.readdir(subpath);
res.json(entries);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EACCES') {
return res.status(403).json({ error: 'Forbidden' });
}
res.status(500).json({ error: 'Internal error' });
}
});
```
### Request Flow
```mermaid
sequenceDiagram
participant Browser
participant Server
participant ACL as Acl Instance
participant VFS as AclVfsClient
participant FS as LocalVFS
Note over Server: Boot
Server->>Server: loadVfsAclFromPostgres
Note over Browser,FS: Per request
Browser->>Server: GET /vfs/owner-uuid/docs
Server->>Server: Extract callerId from auth
Server->>VFS: readdir "docs"
VFS->>ACL: isAllowed callerId, vfs resource, "list"
alt Allowed
ACL-->>VFS: true
VFS->>FS: readdir "docs"
FS-->>VFS: entries
VFS-->>Server: entries
Server-->>Browser: 200 JSON
else Denied
ACL-->>VFS: false
VFS-->>Server: EACCES
Server-->>Browser: 403 Forbidden
end
```
---
## Step 4 — LISTEN/NOTIFY for Real-Time Sync
Use Postgres LISTEN/NOTIFY to keep the ACL in sync without polling:
```sql
-- Trigger function: notify on permission changes
create or replace function notify_vfs_permission_change()
returns trigger as $$
begin
perform pg_notify('vfs_permissions_changed', '');
return coalesce(NEW, OLD);
end;
$$ language plpgsql;
create trigger vfs_permissions_change_trigger
after insert or update or delete
on public.vfs_permissions
for each row
execute function notify_vfs_permission_change();
```
Subscribe in your Node.js server:
```ts
import { Client } from 'pg';
const listener = new Client({ connectionString: process.env.DATABASE_URL });
await listener.connect();
await listener.query('LISTEN vfs_permissions_changed');
listener.on('notification', async () => {
console.log('VFS permissions changed — rebuilding ACL');
const freshAcl = new Acl(new MemoryBackend());
await loadVfsAclFromPostgres(freshAcl);
replaceGlobalAcl(freshAcl);
});
```
### Sync Flow
```mermaid
sequenceDiagram
participant Admin as Owner
participant DB as Postgres
participant Trigger
participant Server
participant ACL as Acl Instance
Admin->>DB: INSERT INTO vfs_permissions
DB->>Trigger: AFTER INSERT
Trigger->>DB: pg_notify vfs_permissions_changed
DB-->>Server: NOTIFY event
Server->>DB: SELECT all vfs_permissions
DB-->>Server: Updated rows
Server->>ACL: Rebuild from fresh data
Note over ACL: Next request uses updated rules
```
---
## Step 5 — Managing Grants via SQL
### Grant access
```sql
-- Give user B read + list on user A's /docs folder
INSERT INTO vfs_permissions (owner_id, grantee_id, resource_path, permissions)
VALUES ('aaa-...', 'bbb-...', '/docs', ARRAY['read', 'list'])
ON CONFLICT (owner_id, grantee_id, resource_path)
DO UPDATE SET permissions = EXCLUDED.permissions;
-- Give user B full access to user A's /shared folder
INSERT INTO vfs_permissions (owner_id, grantee_id, resource_path, permissions)
VALUES ('aaa-...', 'bbb-...', '/shared', ARRAY['read', 'write', 'list', 'mkdir', 'delete'])
ON CONFLICT (owner_id, grantee_id, resource_path)
DO UPDATE SET permissions = EXCLUDED.permissions;
```
### Extend permissions
```sql
-- Add 'write' to the /docs grant
UPDATE vfs_permissions
SET permissions = array_cat(permissions, ARRAY['write'])
WHERE owner_id = 'aaa-...' AND grantee_id = 'bbb-...' AND resource_path = '/docs';
```
### Revoke specific permissions
```sql
-- Remove 'write' from the /docs grant
UPDATE vfs_permissions
SET permissions = array_remove(permissions, 'write')
WHERE owner_id = 'aaa-...' AND grantee_id = 'bbb-...' AND resource_path = '/docs';
```
### Revoke all access
```sql
-- Revoke access to a specific folder
DELETE FROM vfs_permissions
WHERE owner_id = 'aaa-...' AND grantee_id = 'bbb-...' AND resource_path = '/docs';
-- Revoke ALL access (all paths)
DELETE FROM vfs_permissions
WHERE owner_id = 'aaa-...' AND grantee_id = 'bbb-...';
```
---
## Step 6 — Verify RLS Works
Quick sanity check that RLS is enforcing properly:
```sql
-- As owner: sees all their grants
set local app.current_user_id = 'owner-uuid';
select * from vfs_permissions; -- returns owner's grants
-- As grantee: sees only grants targeting them
set local app.current_user_id = 'grantee-uuid';
select * from vfs_permissions; -- returns only grantee's rows
-- As stranger: sees nothing
set local app.current_user_id = 'stranger-uuid';
select * from vfs_permissions; -- empty
```
---
## Summary
| Layer | Enforces | When |
|-------|----------|------|
| **Postgres RLS** | Who can read/modify `vfs_permissions` rows | Every SQL query |
| **LISTEN/NOTIFY** | ACL cache invalidation | On permission changes |
| **Acl Instance** | Fast per-operation permission checks | Every file I/O |
| **AclVfsClient** | EACCES guard before `LocalVFS` | Every VFS API call |
| **LocalVFS** | Root-jail + symlink escape prevention | Every path resolution |
RLS is the lock on the vault. The ACL is the guard at the door. Both enforce the same rules, at different layers.

View File

@ -0,0 +1,290 @@
# Supabase RLS Mirror — VFS ACL Integration Guide
Adopt `@polymech/acl` as the **application-layer mirror** of Supabase Row Level Security for your VFS.
Supabase RLS enforces access at the database level; this library enforces the same rules at the filesystem level — keeping both in sync.
## Architecture
```mermaid
flowchart LR
subgraph Supabase
DB["Postgres + RLS"]
Auth["Auth / JWT"]
end
subgraph Application
ACL["Acl Instance"]
Client["AclVfsClient"]
FS["LocalVFS"]
end
Auth -->|userId| ACL
DB -->|vfs_permissions rows| ACL
ACL -->|guard| Client
Client -->|delegated ops| FS
```
The idea: Supabase owns the **source of truth** for permissions (stored in a `vfs_permissions` table with RLS policies). At server startup or per-request, those rows are loaded into the in-memory `Acl` instance, which then guards every filesystem operation.
---
## Step 1 — Database Schema
Create a `vfs_permissions` table in Supabase to store per-folder grants:
```sql
create table public.vfs_permissions (
id uuid default gen_random_uuid() primary key,
owner_id uuid not null references auth.users(id) on delete cascade,
grantee_id uuid not null references auth.users(id) on delete cascade,
resource_path text not null default '/',
permissions text[] not null default '{}',
created_at timestamptz default now(),
unique (owner_id, grantee_id, resource_path)
);
-- resource_path examples:
-- '/' → entire user folder (root)
-- '/docs' → only the docs subfolder
-- '/photos/pub' → only photos/pub and its children
-- Owner can manage their own grants
alter table public.vfs_permissions enable row level security;
create policy "owners manage their grants"
on public.vfs_permissions
for all
using (auth.uid() = owner_id)
with check (auth.uid() = owner_id);
-- Grantees can read their own grants
create policy "grantees can read their grants"
on public.vfs_permissions
for select
using (auth.uid() = grantee_id);
```
### Valid Permissions
| Permission | VFS Operation |
|-----------|---------------|
| `read` | `stat`, `readfile`, `exists` |
| `list` | `readdir` |
| `write` | `writefile`, `mkfile` |
| `mkdir` | `mkdir` |
| `delete` | `rmfile`, `rmdir` |
| `rename` | `rename` |
| `copy` | `copy` |
---
## Step 2 — Supabase ACL Loader
Fetch grants from Supabase and load them into the ACL at server startup or per-request:
```ts
import { createClient } from '@supabase/supabase-js';
import { Acl, MemoryBackend } from '@polymech/acl';
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);
interface VfsPermissionRow {
owner_id: string;
grantee_id: string;
resource_path: string;
permissions: string[];
}
/**
* Load all VFS permissions from Supabase into an Acl instance.
* Call this at server startup, or per-request for real-time sync.
*/
export async function loadVfsAclFromSupabase(acl: Acl): Promise<void> {
const { data, error } = await supabase
.from('vfs_permissions')
.select('owner_id, grantee_id, resource_path, permissions');
if (error) throw error;
// Group by owner
const byOwner = new Map<string, VfsPermissionRow[]>();
for (const row of data as VfsPermissionRow[]) {
const list = byOwner.get(row.owner_id) ?? [];
list.push(row);
byOwner.set(row.owner_id, list);
}
for (const [ownerId, grants] of byOwner) {
// Owner gets wildcard on their entire tree
const ownerResource = `vfs:${ownerId}:/`;
const ownerRole = `owner:${ownerId}`;
await acl.allow(ownerRole, ownerResource, '*');
await acl.addUserRoles(ownerId, ownerRole);
// Each grantee gets permissions scoped to a specific path
for (const grant of grants) {
const resource = `vfs:${ownerId}:${grant.resource_path}`;
const grantRole = `vfs-grant:${ownerId}:${grant.grantee_id}:${grant.resource_path}`;
await acl.allow(grantRole, resource, grant.permissions);
await acl.addUserRoles(grant.grantee_id, grantRole);
}
}
}
```
---
## Step 3 — Server Integration
Wire it into your Express / Hono / Fastify route handler:
```ts
import { Acl, MemoryBackend } from '@polymech/acl';
import { AclVfsClient } from '@polymech/acl/vfs/AclVfsClient';
import { loadVfsAclFromSupabase } from './supabase-acl-loader.js';
// Boot: load permissions once
const acl = new Acl(new MemoryBackend());
await loadVfsAclFromSupabase(acl);
// Per-request: create a guarded client
app.get('/vfs/:ownerId/*', async (req, res) => {
const callerId = req.auth.userId; // from Supabase JWT
const ownerId = req.params.ownerId;
const subpath = req.params[0] ?? '';
const client = new AclVfsClient(acl, ownerId, callerId, {
root: `/data/vfs/${ownerId}`,
});
try {
const entries = await client.readdir(subpath);
res.json(entries);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EACCES') {
res.status(403).json({ error: 'Access denied' });
} else {
res.status(500).json({ error: 'Internal error' });
}
}
});
```
### Request Flow
```mermaid
sequenceDiagram
participant Browser
participant Server as Express Server
participant ACL as Acl Instance
participant VFS as AclVfsClient
participant FS as LocalVFS
participant SB as Supabase
Note over Server,SB: Boot - one time
Server->>SB: SELECT from vfs_permissions
SB-->>Server: Permission rows
Server->>ACL: loadVfsAclFromSupabase
Note over Browser,FS: Per request
Browser->>Server: GET /vfs/owner-id/photos
Server->>Server: Extract callerId from JWT
Server->>VFS: readdir "photos"
VFS->>ACL: isAllowed callerId, vfs resource, "list"
ACL-->>VFS: true or false
alt Allowed
VFS->>FS: readdir "photos"
FS-->>VFS: File entries
VFS-->>Server: File entries
Server-->>Browser: 200 JSON
else Denied
VFS-->>Server: EACCES
Server-->>Browser: 403 Access denied
end
```
---
## Step 4 — Real-Time Sync (Optional)
To keep the ACL in sync when permissions change without restarting, subscribe to Supabase Realtime:
```ts
supabase
.channel('vfs-permissions')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'vfs_permissions' },
async () => {
// Rebuild ACL from scratch (simple approach)
const freshAcl = new Acl(new MemoryBackend());
await loadVfsAclFromSupabase(freshAcl);
// Swap the reference atomically
replaceGlobalAcl(freshAcl);
},
)
.subscribe();
```
For high-traffic systems, apply incremental updates instead of full rebuilds.
---
## Step 5 — Client-Side Permission Check (UI)
Use the same Supabase RLS policies to show/hide UI elements. The client can query `vfs_permissions` directly (RLS filters automatically by `auth.uid()`):
```ts
const { data } = await supabase
.from('vfs_permissions')
.select('owner_id, permissions')
.eq('grantee_id', currentUserId);
// data = [{ owner_id: '3bb4...', permissions: ['read', 'list'] }]
// Use this to conditionally render write/delete buttons
```
---
## Step 6 — Managing Grants (UI)
Owners manage their own grants through the RLS-protected table:
```ts
// Grant read + list on a specific folder
await supabase.from('vfs_permissions').upsert({
owner_id: myUserId,
grantee_id: targetUserId,
resource_path: '/docs',
permissions: ['read', 'list'],
});
// Revoke access to a specific folder
await supabase
.from('vfs_permissions')
.delete()
.match({ owner_id: myUserId, grantee_id: targetUserId, resource_path: '/docs' });
// Revoke ALL access (all paths)
await supabase
.from('vfs_permissions')
.delete()
.match({ owner_id: myUserId, grantee_id: targetUserId });
```
---
## Summary
| Layer | Responsibility |
|-------|---------------|
| **Supabase RLS** | Source of truth — who can read/modify `vfs_permissions` rows |
| **Acl Instance** | In-memory mirror — fast permission checks per filesystem operation |
| **AclVfsClient** | Guard layer — wraps `LocalVFS`, throws `EACCES` on denied |
| **LocalVFS** | Actual filesystem I/O — jailed to user's root directory |
Both layers enforce the same rules. Supabase RLS protects the data at rest; the ACL library protects the filesystem in motion.

372
packages/acl/src/Acl.ts Normal file
View File

@ -0,0 +1,372 @@
/**
* @polymech/acl Core ACL class
*
* Zend_ACL-inspired role-based access control.
* Pure ESM, async/await, zero lodash/bluebird.
*/
import type { Logger } from 'pino';
import type {
AclGrant,
AclOptions,
BucketNames,
IBackend,
IAcl,
Value,
Values,
} from './interfaces.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function toArray<T>(v: T | T[]): T[] {
return Array.isArray(v) ? v : [v];
}
function allowsBucket(resource: string): string {
return `allows_${resource}`;
}
function keyFromAllowsBucket(str: string): string {
return str.replace(/^allows_/, '');
}
/** Set-based union of two arrays (deduped). */
function union<T>(a: T[], b: T[]): T[] {
return [...new Set([...a, ...b])];
}
/** Items in `a` that are not in `b`. */
function difference<T>(a: T[], b: T[]): T[] {
const set = new Set(b);
return a.filter((x) => !set.has(x));
}
/** Intersection of `a` and `b`. */
function intersect<T>(a: T[], b: T[]): T[] {
const set = new Set(b);
return a.filter((x) => set.has(x));
}
// ---------------------------------------------------------------------------
// Default bucket names
// ---------------------------------------------------------------------------
const DEFAULT_BUCKETS: BucketNames = {
meta: 'meta',
parents: 'parents',
permissions: 'permissions',
resources: 'resources',
roles: 'roles',
users: 'users',
} as const;
// ---------------------------------------------------------------------------
// ACL
// ---------------------------------------------------------------------------
export class Acl implements IAcl {
readonly #backend: IBackend<unknown>;
readonly #buckets: BucketNames;
readonly #logger: Logger | undefined;
constructor(backend: IBackend<unknown>, logger?: Logger, options?: AclOptions) {
this.#backend = backend;
this.#logger = logger;
this.#buckets = { ...DEFAULT_BUCKETS, ...options?.buckets };
}
// -------------------------------------------------------------------------
// allow
// -------------------------------------------------------------------------
async allow(rolesOrGrants: Values | AclGrant[], resources?: Values, permissions?: Values): Promise<void> {
// Overload: allow(grants[])
if (Array.isArray(rolesOrGrants) && rolesOrGrants.length > 0 && typeof rolesOrGrants[0] === 'object') {
return this.#allowBatch(rolesOrGrants as AclGrant[]);
}
const roles = toArray(rolesOrGrants as Values) as string[];
const res = toArray(resources!);
const perms = toArray(permissions!);
const tx = this.#backend.begin();
this.#backend.add(tx, this.#buckets.meta, 'roles', roles);
for (const resource of res) {
for (const role of roles) {
this.#backend.add(tx, allowsBucket(String(resource)), role, perms);
}
}
for (const role of roles) {
this.#backend.add(tx, this.#buckets.resources, role, res);
}
await this.#backend.end(tx);
this.#logger?.debug({ roles, resources: res, permissions: perms }, 'allow');
}
async #allowBatch(grants: AclGrant[]): Promise<void> {
const flat: { roles: Values; resources: Values; permissions: Values }[] = [];
for (const g of grants) {
for (const a of g.allows) {
flat.push({ roles: g.roles, resources: a.resources, permissions: a.permissions });
}
}
for (const item of flat) {
await this.allow(item.roles, item.resources, item.permissions);
}
}
// -------------------------------------------------------------------------
// User ↔ Role
// -------------------------------------------------------------------------
async addUserRoles(userId: Value, roles: Values): Promise<void> {
const rolesArr = toArray(roles);
const tx = this.#backend.begin();
this.#backend.add(tx, this.#buckets.meta, 'users', userId);
this.#backend.add(tx, this.#buckets.users, userId, roles);
for (const role of rolesArr) {
this.#backend.add(tx, this.#buckets.roles, role, userId);
}
await this.#backend.end(tx);
}
async removeUserRoles(userId: Value, roles: Values): Promise<void> {
const rolesArr = toArray(roles);
const tx = this.#backend.begin();
this.#backend.remove(tx, this.#buckets.users, userId, roles);
for (const role of rolesArr) {
this.#backend.remove(tx, this.#buckets.roles, role, userId);
}
await this.#backend.end(tx);
}
async userRoles(userId: Value): Promise<string[]> {
return this.#backend.get(this.#buckets.users, userId);
}
async roleUsers(role: Value): Promise<string[]> {
return this.#backend.get(this.#buckets.roles, role);
}
async hasRole(userId: Value, role: string): Promise<boolean> {
const roles = await this.userRoles(userId);
return roles.includes(role);
}
// -------------------------------------------------------------------------
// Role hierarchy
// -------------------------------------------------------------------------
async addRoleParents(role: string, parents: Values): Promise<void> {
const tx = this.#backend.begin();
this.#backend.add(tx, this.#buckets.meta, 'roles', role);
this.#backend.add(tx, this.#buckets.parents, role, parents);
await this.#backend.end(tx);
}
async removeRoleParents(role: string, parents?: Values): Promise<void> {
const tx = this.#backend.begin();
if (parents) {
this.#backend.remove(tx, this.#buckets.parents, role, parents);
} else {
this.#backend.del(tx, this.#buckets.parents, role);
}
await this.#backend.end(tx);
}
async removeRole(role: string): Promise<void> {
const resources = await this.#backend.get(this.#buckets.resources, role);
const tx = this.#backend.begin();
for (const resource of resources) {
this.#backend.del(tx, allowsBucket(resource), role);
}
this.#backend.del(tx, this.#buckets.resources, role);
this.#backend.del(tx, this.#buckets.parents, role);
this.#backend.del(tx, this.#buckets.roles, role);
this.#backend.remove(tx, this.#buckets.meta, 'roles', role);
await this.#backend.end(tx);
}
// -------------------------------------------------------------------------
// Resources
// -------------------------------------------------------------------------
async removeResource(resource: string): Promise<void> {
const roles = await this.#backend.get(this.#buckets.meta, 'roles');
const tx = this.#backend.begin();
this.#backend.del(tx, allowsBucket(resource), roles);
for (const role of roles) {
this.#backend.remove(tx, this.#buckets.resources, role, resource);
}
await this.#backend.end(tx);
}
// -------------------------------------------------------------------------
// Remove allow / permissions
// -------------------------------------------------------------------------
async removeAllow(role: string, resources: Values, permissions?: Values): Promise<void> {
const res = toArray(resources);
const perms = permissions ? toArray(permissions) : undefined;
await this.#removePermissions(role, res as string[], perms as string[] | undefined);
}
async #removePermissions(role: string, resources: string[], permissions?: string[]): Promise<void> {
const tx = this.#backend.begin();
for (const resource of resources) {
const bucket = allowsBucket(resource);
if (permissions) {
this.#backend.remove(tx, bucket, role, permissions);
} else {
this.#backend.del(tx, bucket, role);
this.#backend.remove(tx, this.#buckets.resources, role, resource);
}
}
await this.#backend.end(tx);
// Clean up resources with empty permission sets (not fully atomic)
const tx2 = this.#backend.begin();
for (const resource of resources) {
const bucket = allowsBucket(resource);
const remaining = await this.#backend.get(bucket, role);
if (remaining.length === 0) {
this.#backend.remove(tx2, this.#buckets.resources, role, resource);
}
}
await this.#backend.end(tx2);
}
// -------------------------------------------------------------------------
// Permission queries
// -------------------------------------------------------------------------
async allowedPermissions(userId: Value, resources: Values): Promise<Record<string, string[]>> {
if (!userId) return {};
const res = toArray(resources) as string[];
const roles = await this.userRoles(userId);
const result: Record<string, string[]> = {};
for (const resource of res) {
result[resource] = await this.#resourcePermissions(roles, resource);
}
return result;
}
async isAllowed(userId: Value, resource: string, permissions: Values): Promise<boolean> {
const roles = await this.#backend.get(this.#buckets.users, userId);
if (roles.length === 0) return false;
return this.areAnyRolesAllowed(roles, resource, permissions);
}
async areAnyRolesAllowed(roles: Values, resource: string, permissions: Values): Promise<boolean> {
const rolesArr = toArray(roles) as string[];
if (rolesArr.length === 0) return false;
const permsArr = toArray(permissions) as string[];
return this.#checkPermissions(rolesArr, resource, permsArr);
}
// -------------------------------------------------------------------------
// What resources
// -------------------------------------------------------------------------
async whatResources(roles: Values, permissions?: Values): Promise<Record<string, string[]> | string[]> {
const rolesArr = toArray(roles) as string[];
const permsArr = permissions ? toArray(permissions) as string[] : undefined;
return this.#permittedResources(rolesArr, permsArr);
}
async #permittedResources(roles: string[], permissions?: string[]): Promise<Record<string, string[]> | string[]> {
const resources = await this.#rolesResources(roles);
if (!permissions) {
const result: Record<string, string[]> = {};
for (const resource of resources) {
result[resource] = await this.#resourcePermissions(roles, resource);
}
return result;
}
const result: string[] = [];
for (const resource of resources) {
const perms = await this.#resourcePermissions(roles, resource);
if (intersect(permissions, perms).length > 0) {
result.push(resource);
}
}
return result;
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
async #rolesParents(roles: string[]): Promise<string[]> {
return this.#backend.union(this.#buckets.parents, roles);
}
async #allRoles(roleNames: string[]): Promise<string[]> {
const parents = await this.#rolesParents(roleNames);
if (parents.length > 0) {
const parentRoles = await this.#allRoles(parents);
return union(roleNames, parentRoles);
}
return roleNames;
}
async #allUserRoles(userId: Value): Promise<string[]> {
const roles = await this.userRoles(userId);
if (roles.length > 0) {
return this.#allRoles(roles);
}
return [];
}
async #rolesResources(roles: string[]): Promise<string[]> {
const allRoles = await this.#allRoles(roles);
let result: string[] = [];
for (const role of allRoles) {
const resources = await this.#backend.get(this.#buckets.resources, role);
result = result.concat(resources);
}
return [...new Set(result)];
}
async #resourcePermissions(roles: string[], resource: string): Promise<string[]> {
if (roles.length === 0) return [];
const perms = await this.#backend.union(allowsBucket(resource), roles);
const parents = await this.#rolesParents(roles);
if (parents.length > 0) {
const parentPerms = await this.#resourcePermissions(parents, resource);
return union(perms, parentPerms);
}
return perms;
}
/**
* Recursively checks whether the given roles satisfy ALL requested permissions
* for a resource, walking up the parent hierarchy.
*
* NOTE: Does not handle circular parent graphs will infinite-loop.
*/
async #checkPermissions(roles: string[], resource: string, permissions: string[]): Promise<boolean> {
const resourcePerms = await this.#backend.union(allowsBucket(resource), roles);
if (resourcePerms.includes('*')) return true;
const remaining = difference(permissions, resourcePerms);
if (remaining.length === 0) return true;
const parents = await this.#backend.union(this.#buckets.parents, roles);
if (parents.length > 0) {
return this.#checkPermissions(parents, resource, remaining);
}
return false;
}
}

View File

@ -0,0 +1,264 @@
/**
* @polymech/acl Functional tests
*
* Exercises the full ACL API against the MemoryBackend.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Acl } from './Acl.js';
import { MemoryBackend } from './data/MemoryBackend.js';
describe('Acl (MemoryBackend)', () => {
let acl: Acl;
beforeEach(async () => {
const backend = new MemoryBackend();
acl = new Acl(backend);
});
// -------------------------------------------------------------------------
// Roles ↔ Users
// -------------------------------------------------------------------------
describe('addUserRoles / userRoles', () => {
it('assigns a single role to a user', async () => {
await acl.addUserRoles('user1', 'admin');
const roles = await acl.userRoles('user1');
expect(roles).toContain('admin');
});
it('assigns multiple roles', async () => {
await acl.addUserRoles('user2', ['editor', 'viewer']);
const roles = await acl.userRoles('user2');
expect(roles).toEqual(expect.arrayContaining(['editor', 'viewer']));
});
});
describe('removeUserRoles', () => {
it('removes a role from a user', async () => {
await acl.addUserRoles('user1', ['admin', 'editor']);
await acl.removeUserRoles('user1', 'admin');
const roles = await acl.userRoles('user1');
expect(roles).not.toContain('admin');
expect(roles).toContain('editor');
});
});
describe('roleUsers', () => {
it('returns users for a given role', async () => {
await acl.addUserRoles('u1', 'admin');
await acl.addUserRoles('u2', 'admin');
const users = await acl.roleUsers('admin');
expect(users).toEqual(expect.arrayContaining(['u1', 'u2']));
});
});
describe('hasRole', () => {
it('returns true when user has the role', async () => {
await acl.addUserRoles('u1', 'admin');
expect(await acl.hasRole('u1', 'admin')).toBe(true);
});
it('returns false when user lacks the role', async () => {
await acl.addUserRoles('u1', 'editor');
expect(await acl.hasRole('u1', 'admin')).toBe(false);
});
});
// -------------------------------------------------------------------------
// Permissions
// -------------------------------------------------------------------------
describe('allow / isAllowed', () => {
it('grants and checks a simple permission', async () => {
await acl.addUserRoles('u1', 'editor');
await acl.allow('editor', 'posts', 'edit');
expect(await acl.isAllowed('u1', 'posts', 'edit')).toBe(true);
expect(await acl.isAllowed('u1', 'posts', 'delete')).toBe(false);
});
it('grants multiple permissions at once', async () => {
await acl.addUserRoles('u1', 'admin');
await acl.allow('admin', 'posts', ['create', 'edit', 'delete']);
expect(await acl.isAllowed('u1', 'posts', 'create')).toBe(true);
expect(await acl.isAllowed('u1', 'posts', 'delete')).toBe(true);
});
it('supports wildcard * permission', async () => {
await acl.addUserRoles('u1', 'superadmin');
await acl.allow('superadmin', 'everything', '*');
expect(await acl.isAllowed('u1', 'everything', 'anything')).toBe(true);
expect(await acl.isAllowed('u1', 'everything', 'whatever')).toBe(true);
});
});
describe('allow — batch grant syntax', () => {
it('grants via AclGrant[] array', async () => {
await acl.addUserRoles('u1', 'member');
await acl.allow([
{
roles: 'member',
allows: [
{ resources: 'profile', permissions: ['read', 'update'] },
{ resources: 'feed', permissions: 'read' },
],
},
]);
expect(await acl.isAllowed('u1', 'profile', 'read')).toBe(true);
expect(await acl.isAllowed('u1', 'profile', 'update')).toBe(true);
expect(await acl.isAllowed('u1', 'feed', 'read')).toBe(true);
expect(await acl.isAllowed('u1', 'feed', 'write')).toBe(false);
});
});
// -------------------------------------------------------------------------
// Role hierarchy
// -------------------------------------------------------------------------
describe('addRoleParents — hierarchical permissions', () => {
it('inherits permissions from parent roles', async () => {
await acl.allow('viewer', 'docs', 'read');
await acl.allow('editor', 'docs', 'write');
await acl.addRoleParents('editor', 'viewer');
await acl.addUserRoles('u1', 'editor');
// editor can write (own) AND read (inherited from viewer)
expect(await acl.isAllowed('u1', 'docs', 'write')).toBe(true);
expect(await acl.isAllowed('u1', 'docs', 'read')).toBe(true);
});
it('supports multi-level hierarchy', async () => {
await acl.allow('base', 'res', 'read');
await acl.allow('mid', 'res', 'write');
await acl.allow('top', 'res', 'admin');
await acl.addRoleParents('mid', 'base');
await acl.addRoleParents('top', 'mid');
await acl.addUserRoles('u1', 'top');
expect(await acl.isAllowed('u1', 'res', 'admin')).toBe(true);
expect(await acl.isAllowed('u1', 'res', 'write')).toBe(true);
expect(await acl.isAllowed('u1', 'res', 'read')).toBe(true);
});
});
describe('removeRoleParents', () => {
it('cuts parent inheritance', async () => {
await acl.allow('viewer', 'docs', 'read');
await acl.allow('editor', 'docs', 'write');
await acl.addRoleParents('editor', 'viewer');
await acl.addUserRoles('u1', 'editor');
// Before removal: editor inherits viewer's read
expect(await acl.isAllowed('u1', 'docs', 'read')).toBe(true);
await acl.removeRoleParents('editor', 'viewer');
// After removal: editor only has write
expect(await acl.isAllowed('u1', 'docs', 'read')).toBe(false);
expect(await acl.isAllowed('u1', 'docs', 'write')).toBe(true);
});
});
// -------------------------------------------------------------------------
// Removal
// -------------------------------------------------------------------------
describe('removeRole', () => {
it('removes the role and its permissions', async () => {
await acl.allow('temp', 'stuff', 'do');
await acl.addUserRoles('u1', 'temp');
expect(await acl.isAllowed('u1', 'stuff', 'do')).toBe(true);
await acl.removeRole('temp');
// areAnyRolesAllowed should fail now for 'temp'
expect(await acl.areAnyRolesAllowed('temp', 'stuff', 'do')).toBe(false);
});
});
describe('removeAllow', () => {
it('removes specific permissions', async () => {
await acl.allow('editor', 'posts', ['read', 'write', 'delete']);
await acl.addUserRoles('u1', 'editor');
await acl.removeAllow('editor', 'posts', 'delete');
expect(await acl.isAllowed('u1', 'posts', 'delete')).toBe(false);
expect(await acl.isAllowed('u1', 'posts', 'read')).toBe(true);
});
it('removes all permissions for a resource when no perms specified', async () => {
await acl.allow('editor', 'posts', ['read', 'write']);
await acl.addUserRoles('u1', 'editor');
await acl.removeAllow('editor', 'posts');
expect(await acl.isAllowed('u1', 'posts', 'read')).toBe(false);
expect(await acl.isAllowed('u1', 'posts', 'write')).toBe(false);
});
});
describe('removeResource', () => {
it('removes the resource from all roles', async () => {
await acl.allow('admin', 'secrets', 'read');
await acl.addUserRoles('u1', 'admin');
expect(await acl.isAllowed('u1', 'secrets', 'read')).toBe(true);
await acl.removeResource('secrets');
expect(await acl.isAllowed('u1', 'secrets', 'read')).toBe(false);
});
});
// -------------------------------------------------------------------------
// Queries
// -------------------------------------------------------------------------
describe('allowedPermissions', () => {
it('returns a map of resource → permissions', async () => {
await acl.addUserRoles('u1', 'editor');
await acl.allow('editor', 'posts', ['read', 'write']);
await acl.allow('editor', 'comments', 'moderate');
const perms = await acl.allowedPermissions('u1', ['posts', 'comments', 'settings']);
expect(perms['posts']).toEqual(expect.arrayContaining(['read', 'write']));
expect(perms['comments']).toContain('moderate');
expect(perms['settings']).toEqual([]);
});
it('returns {} for falsy userId', async () => {
const perms = await acl.allowedPermissions('', ['posts']);
expect(perms).toEqual({});
});
});
describe('areAnyRolesAllowed', () => {
it('returns true if at least one role has the permission', async () => {
await acl.allow('a', 'res', 'read');
expect(await acl.areAnyRolesAllowed(['a', 'b'], 'res', 'read')).toBe(true);
});
it('returns false for empty roles', async () => {
expect(await acl.areAnyRolesAllowed([], 'res', 'read')).toBe(false);
});
});
describe('whatResources', () => {
it('without permissions — returns resource→permissions map', async () => {
await acl.allow('editor', 'posts', ['read', 'write']);
await acl.allow('editor', 'pages', 'read');
const result = await acl.whatResources('editor') as Record<string, string[]>;
expect(Object.keys(result)).toEqual(expect.arrayContaining(['posts', 'pages']));
expect(result['posts']).toEqual(expect.arrayContaining(['read', 'write']));
});
it('with permissions — returns matching resource names', async () => {
await acl.allow('editor', 'posts', ['read', 'write']);
await acl.allow('editor', 'pages', 'read');
const result = await acl.whatResources('editor', 'write') as string[];
expect(result).toContain('posts');
expect(result).not.toContain('pages');
});
});
});

View File

@ -0,0 +1,47 @@
/**
* @polymech/acl File-backed storage
*
* Extends MemoryBackend with JSON read/write via node:fs.
* Intended for dev/testing use, not production.
*/
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { dirname } from 'node:path';
import { MemoryBackend } from './MemoryBackend.js';
import type { IFileStore } from '../interfaces.js';
export class FileBackend extends MemoryBackend implements IFileStore {
readonly #path: string;
constructor(filePath: string) {
super();
this.#path = filePath;
}
/** Load stored ACL data from disk into memory. */
read(path?: string): void {
const target = path ?? this.#path;
try {
const raw = readFileSync(target, 'utf8');
this.buckets = JSON.parse(raw);
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT') {
mkdirSync(dirname(target), { recursive: true });
return;
}
if (err instanceof SyntaxError) {
// Corrupt JSON → reset
writeFileSync(target, '', { mode: 0o600 });
return;
}
throw err;
}
}
/** Persist current ACL data to disk. */
write(path?: string): void {
const target = path ?? this.#path;
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, JSON.stringify(this.buckets, null, 2), { mode: 0o600 });
}
}

View File

@ -0,0 +1,147 @@
/**
* @polymech/acl In-memory backend
*
* Transaction = array of deferred mutations, executed on `end()`.
* All reads are synchronous (wrapped as async for the interface).
*/
import type { IBackend, Value, Values } from '../interfaces.js';
type Transaction = (() => void)[];
type BucketStore = Record<string, Record<string, string[]>>;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function toArray(v: Values): (string | number)[] {
return Array.isArray(v) ? v : [v];
}
function setUnion(a: string[], b: string[]): string[] {
return [...new Set([...a, ...b])];
}
function setDifference(a: string[], b: string[]): string[] {
const set = new Set(b);
return a.filter((x) => !set.has(x));
}
// ---------------------------------------------------------------------------
// MemoryBackend
// ---------------------------------------------------------------------------
export class MemoryBackend implements IBackend<Transaction> {
#buckets: BucketStore = {};
/** Expose raw data (used by FileBackend for serialisation). */
get buckets(): BucketStore {
return this.#buckets;
}
set buckets(data: BucketStore) {
this.#buckets = data;
}
// -- Transaction lifecycle ------------------------------------------------
begin(): Transaction {
return [];
}
async end(transaction: Transaction): Promise<void> {
for (const fn of transaction) {
fn();
}
}
async clean(): Promise<void> {
this.#buckets = {};
}
// -- Reads ----------------------------------------------------------------
async get(bucket: string, key: Value): Promise<string[]> {
return this.#buckets[bucket]?.[key] ?? [];
}
async union(bucket: string, keys: Value[]): Promise<string[]> {
const b = this.#findBucket(bucket);
if (!b) return [];
const result: string[] = [];
for (const key of keys) {
const vals = b[String(key)];
if (vals) result.push(...vals);
}
return [...new Set(result)];
}
async unions(buckets: string[], keys: Value[]): Promise<Record<string, string[]>> {
const result: Record<string, string[]> = {};
for (const bucket of buckets) {
const b = this.#buckets[bucket];
if (!b) {
result[bucket] = [];
continue;
}
const merged: string[] = [];
for (const key of keys) {
const vals = b[String(key)];
if (vals) merged.push(...vals);
}
result[bucket] = [...new Set(merged)];
}
return result;
}
// -- Writes (queued in transaction) ---------------------------------------
add(transaction: Transaction, bucket: string, key: Value, values: Values): void {
const vals = toArray(values).map(String);
transaction.push(() => {
this.#buckets[bucket] ??= {};
const existing = this.#buckets[bucket]![String(key)] ?? [];
this.#buckets[bucket]![String(key)] = setUnion(existing, vals);
});
}
del(transaction: Transaction, bucket: string, keys: Values): void {
const keysArr = toArray(keys).map(String);
transaction.push(() => {
const b = this.#buckets[bucket];
if (!b) return;
for (const k of keysArr) {
delete b[k];
}
});
}
remove(transaction: Transaction, bucket: string, key: Value, values: Values): void {
const vals = toArray(values).map(String);
transaction.push(() => {
const b = this.#buckets[bucket];
if (!b) return;
const old = b[String(key)];
if (old) {
b[String(key)] = setDifference(old, vals);
}
});
}
// -- Internal -------------------------------------------------------------
/**
* Find a bucket by exact name or by regex match (legacy compat).
*/
#findBucket(name: string): Record<string, string[]> | undefined {
if (this.#buckets[name]) return this.#buckets[name];
for (const key of Object.keys(this.#buckets)) {
if (new RegExp(`^${key}$`).test(name)) {
return this.#buckets[key];
}
}
return undefined;
}
}

23
packages/acl/src/index.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* @polymech/acl Public API
*/
export { Acl } from './Acl.js';
export { MemoryBackend } from './data/MemoryBackend.js';
export { FileBackend } from './data/FileBackend.js';
export type {
IAcl,
IBackend,
IFileStore,
AclGrant,
AclAllow,
AclOptions,
BucketNames,
Value,
Values,
} from './interfaces.js';
// VFS
export { AclVfsClient } from './vfs/AclVfsClient.js';
export { DecoratedVfsClient } from './vfs/DecoratedVfsClient.js';
export { loadVfsSettings, vfsResource, resourceChain } from './vfs/vfs-acl.js';
export type { VfsSettings, VfsAclEntry } from './vfs/vfs-acl.js';

View File

@ -0,0 +1,128 @@
/**
* ACL-Guarded VFS Client
*
* Wraps `LocalVFS` and checks every operation against an `Acl` instance
* before delegating. Throws EACCES if the caller lacks the required permission.
*
* Path-aware: walks the resource chain from the target path up to the root
* and grants access if ANY ancestor resource allows the permission.
*
* Permission mapping:
* readfile / stat "read"
* readdir "list"
* writefile / mkfile "write"
* mkdir / mkdirP "mkdir"
* rmfile / rmdir "delete"
* rename "rename"
* copy "copy"
*/
import type { ReadStream } from 'node:fs';
import type { Acl } from '../Acl.js';
import type { INode } from './fs/VFS.js';
import { LocalVFS, type IDefaultParameters } from './fs/Local.js';
import { resourceChain } from './vfs-acl.js';
export class AclVfsClient {
readonly #acl: Acl;
readonly #local: LocalVFS;
readonly #ownerId: string;
readonly #callerId: string;
/**
* @param acl Populated Acl instance (call `loadVfsSettings` first)
* @param ownerId UUID of the folder owner
* @param callerId UUID of the user performing the operation
* @param fsOpts LocalVFS options (must include `root`)
*/
constructor(acl: Acl, ownerId: string, callerId: string, fsOpts: IDefaultParameters) {
this.#acl = acl;
this.#local = new LocalVFS(fsOpts);
this.#ownerId = ownerId;
this.#callerId = callerId;
}
// ── Guards ──────────────────────────────────────────────────────
/**
* Walk the resource chain from most-specific path to root.
* If ANY level grants the permission, access is allowed.
* This means a grant on `/` covers the entire tree.
*/
async #guard(permission: string, path: string): Promise<void> {
const chain = resourceChain(this.#ownerId, path);
for (const resource of chain) {
const allowed = await this.#acl.isAllowed(this.#callerId, resource, permission);
if (allowed) return;
}
const err = new Error(
`EACCES: user '${this.#callerId}' lacks '${permission}' on path '${path}'`,
);
(err as NodeJS.ErrnoException).code = 'EACCES';
throw err;
}
// ── Read operations ─────────────────────────────────────────────
async stat(path: string): Promise<INode> {
await this.#guard('read', path);
return this.#local.stat(path);
}
async readdir(path: string): Promise<INode[]> {
await this.#guard('list', path);
return this.#local.readdir(path);
}
async readfile(path: string, options?: Record<string, unknown>): Promise<{ stream: ReadStream; meta: unknown }> {
await this.#guard('read', path);
return this.#local.readfile(path, options);
}
async exists(path: string): Promise<boolean> {
await this.#guard('read', path);
return this.#local.exists(path);
}
// ── Write operations ────────────────────────────────────────────
async writefile(path: string, content: string | Buffer, options?: Record<string, unknown>): Promise<void> {
await this.#guard('write', path);
return this.#local.writefile(path, content, options);
}
async mkfile(path: string): Promise<void> {
await this.#guard('write', path);
return this.#local.mkfile(path);
}
async mkdir(path: string): Promise<void> {
await this.#guard('mkdir', path);
return this.#local.mkdir(path, { recursive: true });
}
// ── Delete operations ───────────────────────────────────────────
async rmfile(path: string): Promise<void> {
await this.#guard('delete', path);
return this.#local.rmfile(path);
}
async rmdir(path: string): Promise<void> {
await this.#guard('delete', path);
return this.#local.rmdir(path);
}
// ── Move / Copy ─────────────────────────────────────────────────
async rename(from: string, to: string): Promise<void> {
await this.#guard('rename', from);
return this.#local.rename(from, to);
}
async copy(from: string, to: string): Promise<void> {
await this.#guard('copy', from);
return this.#local.copy(from, to);
}
}

View File

@ -0,0 +1,144 @@
/**
* ACL-Guarded VFS Client Decorator Edition 🎭
*
* Same behaviour as AclVfsClient, but uses TC39 Stage-3 method decorators
* to declare permission guards declaratively instead of imperative #guard() calls.
*
* Usage:
* @aclGuard('read') checks "read" permission on the first arg (path)
* @aclGuard('list') checks "list" permission
* @aclGuard('write') checks "write" permission
* etc
*
* The decorator extracts `path` from the first argument and walks
* the resource chain from that path up to "/" same as AclVfsClient.
*/
import type { ReadStream } from 'node:fs';
import type { Acl } from '../Acl.js';
import type { INode } from './fs/VFS.js';
import { LocalVFS, type IDefaultParameters } from './fs/Local.js';
import { resourceChain } from './vfs-acl.js';
// ---------------------------------------------------------------------------
// Decorator factory
// ---------------------------------------------------------------------------
/**
* TC39 Stage-3 method decorator.
*
* Intercepts the method call, extracts the first argument as `path`,
* walks the resource chain, and throws EACCES if denied.
*/
function aclGuard(permission: string) {
return function <T extends DecoratedVfsClient, A extends [string, ...unknown[]], R>(
target: (this: T, ...args: A) => Promise<R>,
context: ClassMethodDecoratorContext<T, (this: T, ...args: A) => Promise<R>>,
) {
const methodName = String(context.name);
return async function (this: T, ...args: A): Promise<R> {
const path = args[0];
const chain = resourceChain(this.ownerId, path);
for (const resource of chain) {
const allowed = await this.acl.isAllowed(this.callerId, resource, permission);
if (allowed) {
return target.call(this, ...args);
}
}
const err = new Error(
`EACCES: user '${this.callerId}' lacks '${permission}' on path '${path}' [${methodName}]`,
);
(err as NodeJS.ErrnoException).code = 'EACCES';
throw err;
};
};
}
// ---------------------------------------------------------------------------
// Client
// ---------------------------------------------------------------------------
/**
* Decorator-based ACL VFS client.
*
* Properties are public (not #private) so the decorator can access them.
* This is the trade-off: decorators can't reach private fields.
*/
export class DecoratedVfsClient {
readonly acl: Acl;
readonly local: LocalVFS;
readonly ownerId: string;
readonly callerId: string;
constructor(acl: Acl, ownerId: string, callerId: string, fsOpts: IDefaultParameters) {
this.acl = acl;
this.local = new LocalVFS(fsOpts);
this.ownerId = ownerId;
this.callerId = callerId;
}
// ── Read ────────────────────────────────────────────────────────
@aclGuard('read')
async stat(path: string): Promise<INode> {
return this.local.stat(path);
}
@aclGuard('list')
async readdir(path: string): Promise<INode[]> {
return this.local.readdir(path);
}
@aclGuard('read')
async readfile(path: string, options?: Record<string, unknown>): Promise<{ stream: ReadStream; meta: unknown }> {
return this.local.readfile(path, options);
}
@aclGuard('read')
async exists(path: string): Promise<boolean> {
return this.local.exists(path);
}
// ── Write ───────────────────────────────────────────────────────
@aclGuard('write')
async writefile(path: string, content: string | Buffer, options?: Record<string, unknown>): Promise<void> {
return this.local.writefile(path, content, options);
}
@aclGuard('write')
async mkfile(path: string): Promise<void> {
return this.local.mkfile(path);
}
@aclGuard('mkdir')
async mkdir(path: string): Promise<void> {
return this.local.mkdir(path, { recursive: true });
}
// ── Delete ──────────────────────────────────────────────────────
@aclGuard('delete')
async rmfile(path: string): Promise<void> {
return this.local.rmfile(path);
}
@aclGuard('delete')
async rmdir(path: string): Promise<void> {
return this.local.rmdir(path);
}
// ── Move / Copy ─────────────────────────────────────────────────
@aclGuard('rename')
async rename(from: string, to: string): Promise<void> {
return this.local.rename(from, to);
}
@aclGuard('copy')
async copy(from: string, to: string): Promise<void> {
return this.local.copy(from, to);
}
}

View File

@ -0,0 +1,277 @@
import {
join,
resolve as pathResolve,
normalize as pathNormalize,
dirname,
basename,
sep as pathSep,
} from 'path';
import { createReadStream, readFileSync, existsSync, type ReadStream, type Stats } from 'fs';
import { sanitizeSubpath } from '../path-sanitizer.js';
import { realpath, lstat, stat, readdir, mkdir, rm, rename, cp, writeFile, access } from 'fs/promises';
import ignoreModule from 'ignore';
import type { INode } from './VFS.js';
import type { FileResource } from './Resource.js';
import Mime from 'mime';
// Handle CJS/ESM interop — ignore exports { default: fn, isPathValid: fn }
const createIgnore: () => any = (typeof ignoreModule === 'function' ? ignoreModule : (ignoreModule as any).default);
export interface IDefaultParameters {
readonly root: string;
nopty?: boolean;
local?: boolean;
metapath?: boolean;
defaultEnv?: any;
umask?: string | number;
checkSymlinks?: boolean;
wsmetapath?: boolean;
testing?: boolean;
}
export class LocalVFS {
private fsOptions: IDefaultParameters;
private root: string;
private base: string;
private umask: number;
private ig: any = null;
constructor(fsOptions: IDefaultParameters, resource?: FileResource) {
if (!fsOptions.root) {
throw new Error('root is a required option');
}
this.fsOptions = fsOptions;
this.root = pathNormalize(fsOptions.root);
if (pathSep === '/' && this.root[0] !== '/') {
throw new Error('root path must start in /');
}
if (this.root[this.root.length - 1] !== pathSep) {
this.root += pathSep;
}
if (pathSep === '\\') {
this.root = this.root.replace(/\\/g, '/');
}
this.base = this.root.substr(0, this.root.length - 1);
this.umask = (this.fsOptions.umask as number) || Number.parseInt('0750', 8);
// Load .gitignore if present in mount root
const gitignorePath = join(this.root, '.gitignore');
if (existsSync(gitignorePath)) {
const patterns = readFileSync(gitignorePath, 'utf-8');
this.ig = createIgnore().add(patterns);
}
}
/**
* Check if a relative path is ignored by .gitignore rules.
*/
isIgnored(relativePath: string): boolean {
if (!this.ig) return false;
// Normalize: strip leading slash, use forward slashes
const clean = relativePath.replace(/\\/g, '/').replace(/^\//, '');
if (!clean) return false;
return this.ig.ignores(clean);
}
/**
* SECURITY: Resolve a relative path to an absolute path within the jail.
* Layer 2 of defense-in-depth uses sanitizeSubpath for input validation,
* then enforces the root boundary via realpath + prefix check.
*/
private async resolvePath(path: string, options?: { checkSymlinks?: boolean }): Promise<string> {
// Sanitize input through the shared module (strips traversal, null bytes, etc.)
// The sanitizer returns '' for root-directory references like '' or '.'
const sanitized = sanitizeSubpath(path);
// Join with root
let resolved = join(this.root, sanitized);
// Resolve symlinks to prevent symlink escape (default: on)
const checkSymlinks = options?.checkSymlinks !== false;
if (checkSymlinks) {
try {
resolved = await realpath(resolved);
} catch {
// File may not exist yet (e.g. for write) — normalize instead
resolved = pathResolve(resolved);
}
} else {
resolved = pathResolve(resolved);
}
// Normalize slashes (Windows compat)
resolved = resolved.replace(/\\/g, '/');
// Enforce root jail — resolved path MUST be root or start with root/
const norm = (s: string) => {
let n = s.replace(/\\/g, '/').replace(/\/+$/, '');
if (pathSep === '\\') n = n.toLowerCase();
return n;
};
const resolvedNorm = norm(resolved);
const rootNorm = norm(this.root);
if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(rootNorm + '/')) {
const err: any = new Error(`EACCESS: '${resolved}' escapes root '${this.root}'`);
err.code = 'EACCESS';
throw err;
}
return resolved;
}
async stat(path: string, options?: any): Promise<INode> {
const dir = await this.resolvePath(dirname(path));
const file = basename(path);
path = join(dir, file);
return this.createStatEntry(file, path);
}
private async createStatEntry(file: string, fullpath: string): Promise<INode> {
const s = await lstat(fullpath);
const relativePath = '/' + fullpath.replace(/\\/g, '/').replace(this.root, '');
const entry: INode = {
name: file,
path: relativePath,
mime: Mime.getType(file) || '',
mtime: s.mtime.valueOf(),
size: s.size,
parent: dirname(relativePath),
type: '',
};
if (s.isDirectory()) {
entry.mime = 'inode/directory';
}
return entry;
}
async readfile(path: string, options: any = {}): Promise<{ stream: ReadStream; meta: any }> {
const realp = await this.resolvePath(path);
const s = await stat(realp);
if (s.isDirectory()) {
const err: any = new Error('EISDIR: Requested resource is a directory');
err.code = 'EISDIR';
throw err;
}
const meta = {
size: s.size,
etag: this.calcEtag(s),
};
if (options.head) {
return { stream: null as any, meta };
}
const stream = createReadStream(realp, options);
return { stream, meta };
}
async writefile(path: string, content: string | Buffer, options: any = {}): Promise<void> {
const realp = await this.resolvePath(path);
// Ensure parent directory exists (equivalent to fs-extra outputFile)
await mkdir(dirname(realp), { recursive: true });
await writeFile(realp, content, options);
}
async readdir(path: string, options?: any): Promise<INode[]> {
const realp = await this.resolvePath(path);
const s = await stat(realp);
if (!s.isDirectory()) {
const err: any = new Error('ENOTDIR: Requested resource is not a directory');
err.code = 'ENOTDIR';
throw err;
}
const files = await readdir(realp);
// Filter out .gitignore'd entries
const filtered = this.ig
? files.filter(file => {
// Build relative path from mount root for ignore matching
// Normalize both sides: strip trailing slashes and use forward slashes
const realpNorm = realp.replace(/\\/g, '/').replace(/\/+$/, '');
const rootNorm = this.root.replace(/\/+$/, '');
let relDir = realpNorm.startsWith(rootNorm)
? realpNorm.substring(rootNorm.length).replace(/^\//, '')
: '';
const relPath = relDir ? `${relDir}/${file}` : file;
return !this.ig!.ignores(relPath);
})
: files;
const entries = await Promise.all(
filtered.map((file) => this.createStatEntry(file, join(realp, file)))
);
return entries;
}
async mkfile(path: string, options: any = {}): Promise<void> {
await this.writefile(path, '', options);
}
async mkdir(path: string, options: any = {}): Promise<void> {
const realp = await this.resolvePath(path);
await mkdir(realp, options);
}
async mkdirP(path: string, options: any = {}): Promise<void> {
const realp = await this.resolvePath(path, { checkSymlinks: false });
await mkdir(realp, { ...options, recursive: true });
}
async rmfile(path: string, options?: any): Promise<void> {
const realp = await this.resolvePath(path);
await rm(realp, { recursive: true, force: true });
}
async rmdir(path: string, options: any = {}): Promise<void> {
const realp = await this.resolvePath(path);
await rm(realp, { recursive: true, force: true });
}
async rename(from: string, to: string, options: any = {}): Promise<void> {
const frompath = await this.resolvePath(from);
const topath = await this.resolvePath(to);
await rename(frompath, topath);
}
async copy(from: string, to: string, options: any = {}): Promise<void> {
const frompath = await this.resolvePath(from);
const topath = await this.resolvePath(to);
await cp(frompath, topath, { recursive: true });
}
exists(path: string): Promise<boolean> {
return new Promise(async (resolve) => {
try {
const realp = await this.resolvePath(path);
await access(realp);
resolve(true);
} catch (err) {
resolve(false);
}
});
}
private calcEtag(s: Stats): string {
return (
(s.isFile() ? '' : 'W/') +
'"' +
(s.ino || 0).toString(36) +
'-' +
s.size.toString(36) +
'-' +
s.mtime.valueOf().toString(36) +
'"'
);
}
}

View File

@ -0,0 +1,71 @@
// tslint:disable-next-line:interface-name
export interface Hash<T> {
[ id: string ]: T;
}
// tslint:disable-next-line:interface-name
export interface List<T> {
[index: number]: T;
length: number;
}
/**
* Interface of the simple literal object with any string keys.
*/
export interface IObjectLiteral {
[key: string]: any;
}
/**
* Represents some Type of the Object.
*/
export type ObjectType<T> = { new (): T } | Function;
/**
* Same as Partial<T> but goes deeper and makes Partial<T> all its properties and sub-properties.
*/
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export interface IDelimitter {
begin: '%';
end: '%';
}
export enum EResourceType {
JS_HEADER_INCLUDE = <any> 'JS-HEADER-INCLUDE',
JS_HEADER_SCRIPT_TAG = <any> 'JS-HEADER-SCRIPT-TAG',
CSS = <any> 'CSS',
FILE_PROXY = <any> 'FILE_PROXY'
}
export interface IResource {
type?: EResourceType;
name?: string;
url?: string;
enabled?: boolean;
label?: string;
}
export type IResourceProperty = IObjectLiteral & {};
export interface IFileResource {
readOnly?: boolean;
label?: string;
path?: string;
vfs?: string;
options?: IObjectLiteral;
}
export function DefaultDelimitter(): IDelimitter {
return {
begin: '%',
end: '%'
};
}
export interface IResourceDriven {
configPath?: string | null;
relativeVariables: any;
absoluteVariables: any;
}
export type FileResource = IResource & IFileResource;

View File

@ -0,0 +1,142 @@
/**
* Node types
*
* @export
* @enum {string}
*/
export enum ENodeType {
FILE = <any>'file',
DIR = <any>'dir',
SYMLINK = <any>'symlink',
OTHER = <any>'other',
BLOCK = <any>'block'
}
/**
* General features of a VFS
*
* @export
* @enum {number}
*/
export enum ECapabilties {
VERSIONED = 0, // VCS
CHANGE_MESSAGE = 1, // changes require additional comments
META = 2, // more meta data per node
MIME = 3, // VFS has native methods to determine the mime type
AUTHORS = 4, // VFS nodes can have different owners/authors, used by VCS
META_TREE = 5, // VFS has non INode tree nodes (VCS branches, tags, commits,..)
ROOT = 6, // VFS can have an root path prefix, eg. the user's home directory,
REMOTE_CONNECTION = 7 // VFS has a remote connection
}
/**
* Supported file operations
*
* @export
* @enum {number}
*/
export enum EOperations {
LS = 0,
RENAME = 1,
COPY = 2,
DELETE = 3,
MOVE = 4,
GET = 5,
SET = 6
}
/**
* General presentation structure for clients
*
* @export
* @interface INode
*/
export interface INode {
name: string;
path: string;
size: number;
mtime?: number;
mime?: string;
parent: string;
mount?: string;
children?: INode[];
// back compat props for xfile@0.x series
owner?: any;
_EX?: boolean;
isDir?: boolean;
directory?: boolean;
fileType?: string;
sizeBytes?: number;
type: string;
}
export type INodeEx = INode & {
err: any;
linkStatErr: any | null;
link: null;
linkErr: null;
linkStat: null;
};
// tslint:disable-next-line:interface-name
export interface VFS_PATH {
mount: string;
path: string;
}
export interface IMount {
name: string;
type: string;
path: string;
}
export interface IVFSConfig {
configPath: string;
mounts: IMount[];
}
/**
*
* These flags are used to build the result, adaptive.
* @TODO: sync with dgrid#configureColumn
* @export
* @enum {number}
*/
export enum NODE_FIELDS {
SHOW_ISDIR = 1602,
SHOW_OWNER = 1604,
SHOW_MIME = 1608,
SHOW_SIZE = 1616,
SHOW_PERMISSIONS = 1632,
SHOW_TIME = 1633,
// @TODO: re-impl. du -ahs/x for windows
SHOW_FOLDER_SIZE = 1634,
SHOW_FOLDER_HIDDEN = 1635,
SHOW_TYPE = 1636,
SHOW_MEDIA_INFO = 1637
}
export class MountManager {
private mounts: IMount[];
constructor(mounts: IMount[]) {
this.mounts = mounts;
}
findByName(name: string): IMount | undefined {
return this.mounts.find(m => m.name === name);
}
resolve(vfsPath: string): { mount: IMount, path: string } {
const parts = vfsPath.split(':');
const mountName = parts[0] ?? '';
const path = parts.slice(1).join(':');
const mount = this.findByName(mountName);
if (!mount) {
throw new Error(`Mount not found: ${mountName}`);
}
return { mount, path };
}
}

View File

@ -0,0 +1,4 @@
export * from './Local.js';
export * from './VFS.js';

View File

@ -0,0 +1,252 @@
/**
* VFS Path Sanitizer Defense-in-depth path validation.
*
* Combines filename sanitization (illegal chars, Windows reserved names, control chars)
* with path traversal prevention (encoded traversal, parent directory escape, null bytes,
* home directory escape, absolute path injection, double-encoding attacks).
*
* Usage:
* import { sanitizeSubpath, sanitizeFilename } from './path-sanitizer.js';
*
* // For VFS subpaths (relative paths from mount root)
* const safe = sanitizeSubpath(userInput); // throws on violation
*
* // For individual filenames
* const name = sanitizeFilename(userInput); // strips illegal chars
*/
import { normalize, join } from 'path';
// --- Filename sanitization regexes ---
/** Characters illegal in filenames on most filesystems */
const ILLEGAL_CHARS_RE = /[/?<>\\:*|"]/g;
/** ASCII/Unicode control characters */
const CONTROL_CHARS_RE = /[\x00-\x1f\x80-\x9f]/g;
/** Reserved filenames: just dots (., .., ...) */
const RESERVED_DOTS_RE = /^\.+$/;
/** Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) */
const WINDOWS_RESERVED_RE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
/** Windows trailing dots/spaces (not allowed) */
const WINDOWS_TRAILING_RE = /[. ]+$/;
// --- Path traversal regexes ---
/** Encoded traversal sequences to decode before checking */
const DECODE_PATTERNS = [
{ regex: /%2e/gi, replacement: '.' },
{ regex: /%2f/gi, replacement: '/' },
{ regex: /%5c/gi, replacement: '\\' },
];
/** Parent directory traversal: /../, \..\, or standalone .. */
const PARENT_DIR_RE = /[/\\]\.\.[/\\]/g;
/** Characters not allowed in VFS paths */
const NOT_ALLOWED_RE = /[:$!'"`@+|=]/g;
// --- Error helper ---
function forbidden(reason: string): never {
const err: any = new Error(`EFORBIDDEN: ${reason}`);
err.code = 'EFORBIDDEN';
throw err;
}
// --- Public API ---
/**
* Sanitize and validate a VFS subpath (relative path from mount root).
* Rejects or strips dangerous patterns. Returns a clean, normalized relative path.
*
* Throws EFORBIDDEN on:
* - Null bytes
* - Double-encoded sequences (%25)
* - Home directory escape (~/)
* - Absolute paths (/foo, C:\foo)
* - Path traversal after full normalization
*/
export function sanitizeSubpath(input: string): string {
if (typeof input !== 'string') {
forbidden('input must be a string');
}
// 0. Empty path is valid (root directory)
if (!input || input.trim() === '') return '';
// ── TAMPER DETECTION (reject immediately) ──────────────────────
// 1. Reject null bytes immediately
if (input.includes('\0')) {
forbidden('null byte in path');
}
// 2. Reject double-encoded sequences (%25 = encoded %)
if (/%25/i.test(input)) {
forbidden('double-encoded path segment');
}
// 3. Reject home directory escape
if (input.startsWith('~/') || input === '~') {
forbidden('home directory access denied');
}
// 4. Decode traversal-encoded sequences to inspect the real intent
let decoded = input;
for (const { regex, replacement } of DECODE_PATTERNS) {
decoded = decoded.replace(regex, replacement);
}
// 5. Strip not-allowed characters (=, $, etc.) to reveal hidden traversal
// e.g. ..=%5c → ..=\ → after strip = → ..\ → traversal!
const stripped = decoded.replace(NOT_ALLOWED_RE, '');
// 6. Normalize backslashes to forward slashes for uniform detection
const normalized = stripped.replace(/[\\]/g, '/');
// 7. DETECT TRAVERSAL — any ".." segment in the fully-decoded/stripped path
// is a tampering attempt, regardless of encoding tricks
if (PARENT_DIR_RE.test(normalized + '/') || // /../ mid-path
normalized.startsWith('../') || // ../ at start
normalized === '..' || // bare ..
normalized.endsWith('/..') || // /.. at end
/\/\.\.\//g.test('/' + normalized + '/')) { // catch-all
forbidden('path traversal');
}
// 8. Strip leading slashes (common from URL path extraction, not an attack)
const cleaned = normalized.replace(/^\/+/, '');
// 9. Reject absolute paths (Windows drive letters like C:\)
if (/^[a-zA-Z]:/.test(cleaned)) {
forbidden('absolute path');
}
// ── NORMALIZATION (clean paths only reach here) ────────────────
let sanitized = cleaned;
// 10. Collapse multiple slashes
sanitized = sanitized.replace(/\/+/g, '/');
// 10. Use path.normalize for OS-level normalization
sanitized = normalize(sanitized);
// 11. Backslash-to-forward-slash (Windows normalize may re-introduce)
sanitized = sanitized.replace(/[\\]/g, '/');
// 12. Strip leading/trailing slashes (must be relative)
sanitized = sanitized.replace(/^\/+/, '').replace(/\/+$/, '');
// 13. Handle "." result (root directory reference)
sanitized = sanitized === '.' ? '' : sanitized;
// 14. FINAL GUARD — if any ".." segments survived, reject
const segments = sanitized.split('/');
if (segments.some(s => s === '..')) {
forbidden('path traversal survived normalization');
}
return sanitized;
}
// --- Write-operation name sanitization ---
/** Strip everything except alphanumeric, hyphens, underscores, spaces */
const UNSAFE_CHARS_RE = /[^a-zA-Z0-9_\- ]/g;
/** Max length for a single path segment (leaves room within Windows 260-char path limit) */
const MAX_SEGMENT_LENGTH = 200;
/**
* Sanitize every segment of a write-path by stripping unsafe characters and
* truncating overly long names.
*
* **Allowed characters**: `a-z A-Z 0-9 _ - (space)`
*
* Rules:
* - All characters outside the allowlist are stripped.
* - Directory segments have dots stripped entirely.
* - File segments (last) preserve the **last** dot + extension; extra dots are stripped.
* - Each segment is truncated to {@link MAX_SEGMENT_LENGTH} characters.
* - When `isDirectory` is true, ALL segments are treated as directories (no dots).
*
* Call this AFTER sanitizeSubpath operates on the already-cleaned relative path.
* Returns the sanitized path (may differ from input).
*/
export function sanitizeWritePath(subpath: string, opts?: { isDirectory?: boolean }): string {
if (!subpath) return '';
const segments = subpath.split('/');
const allDirs = opts?.isDirectory ?? false;
const cleaned: string[] = [];
for (let i = 0; i < segments.length; i++) {
let seg = segments[i];
if (!seg) continue;
const isLast = i === segments.length - 1;
if (isLast && !allDirs) {
// --- File segment: preserve last extension ---
const lastDot = seg.lastIndexOf('.');
if (lastDot > 0) {
const name = seg.substring(0, lastDot).replace(UNSAFE_CHARS_RE, '').replace(/\./g, '');
const ext = seg.substring(lastDot + 1).replace(UNSAFE_CHARS_RE, '');
seg = ext ? `${name}.${ext}` : name;
} else {
seg = seg.replace(UNSAFE_CHARS_RE, '');
}
} else {
// --- Directory segment: strip dots AND unsafe chars ---
seg = seg.replace(UNSAFE_CHARS_RE, '').replace(/\./g, '');
}
// Trim leading/trailing whitespace
seg = seg.trim();
// Truncate to max length
if (seg.length > MAX_SEGMENT_LENGTH) {
seg = seg.substring(0, MAX_SEGMENT_LENGTH).trim();
}
// Skip empty segments (result of stripping all chars)
if (seg) cleaned.push(seg);
}
return cleaned.join('/');
}
/**
* Sanitize a single filename (not a path no slashes allowed).
* Strips illegal characters, control characters, Windows reserved names.
* Returns the sanitized filename, truncated to 255 bytes.
*/
export function sanitizeFilename(input: string, replacement: string = ''): string {
if (typeof input !== 'string') {
throw new Error('Input must be a string');
}
let sanitized = input
.replace(ILLEGAL_CHARS_RE, replacement)
.replace(CONTROL_CHARS_RE, replacement)
.replace(RESERVED_DOTS_RE, replacement)
.replace(WINDOWS_RESERVED_RE, replacement)
.replace(WINDOWS_TRAILING_RE, replacement);
// Truncate to 255 bytes (UTF-8 safe)
const encoder = new TextEncoder();
const encoded = encoder.encode(sanitized);
if (encoded.length > 255) {
const decoder = new TextDecoder();
sanitized = decoder.decode(encoded.slice(0, 255));
// Remove any truncated multi-byte char fragments
sanitized = sanitized.replace(/\uFFFD$/, '');
}
return sanitized;
}

View File

@ -0,0 +1,102 @@
/**
* VFS ACL Bridge
*
* Reads per-user `vfs-settings.json` files and populates an Acl instance
* with fine-grained, path-scoped permissions for each user's VFS folder.
*
* Resource naming: `vfs:<ownerId>:<path>` e.g. `vfs:3bb4cfbf-...:/docs`
*
* Permissions: read | write | list | mkdir | delete | rename | copy
*/
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Acl } from '../Acl.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VfsAclEntry {
userId: string;
/** Scoped path — defaults to "/" (entire folder). */
path?: string;
permissions: string[];
}
export interface VfsSettings {
owner: string;
acl: VfsAclEntry[];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Canonical resource name for a path inside a user's VFS folder.
*
* vfsResource('aaa', '/') 'vfs:aaa:/'
* vfsResource('aaa', '/docs') 'vfs:aaa:/docs'
*/
export function vfsResource(ownerId: string, resourcePath = '/'): string {
return `vfs:${ownerId}:${resourcePath}`;
}
/**
* Build the list of resources to check, from most-specific to root.
*
* resourceChain('aaa', 'docs/sub/file.txt')
* ['vfs:aaa:/docs/sub/file.txt', 'vfs:aaa:/docs/sub', 'vfs:aaa:/docs', 'vfs:aaa:/']
*
* The guard walks this chain top-down; if ANY level allows, access is granted.
*/
export function resourceChain(ownerId: string, subpath: string): string[] {
const clean = subpath.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
const segments = clean ? clean.split('/') : [];
const chain: string[] = [];
// Build from most-specific to least-specific
for (let i = segments.length; i > 0; i--) {
chain.push(vfsResource(ownerId, '/' + segments.slice(0, i).join('/')));
}
// Always include root
chain.push(vfsResource(ownerId, '/'));
return chain;
}
// ---------------------------------------------------------------------------
// Loader
// ---------------------------------------------------------------------------
/**
* Load `vfs-settings.json` from a user's VFS folder and apply the ACL rules.
*
* - The **owner** always gets `*` on `/` (entire tree).
* - Each ACL entry grants permissions scoped to `entry.path` (default: `/`).
*/
export async function loadVfsSettings(acl: Acl, userDir: string): Promise<VfsSettings | null> {
const settingsPath = join(userDir, 'vfs-settings.json');
if (!existsSync(settingsPath)) return null;
const raw = readFileSync(settingsPath, 'utf8');
const settings: VfsSettings = JSON.parse(raw);
// Owner role — full access on entire tree
const ownerRole = `owner:${settings.owner}`;
await acl.allow(ownerRole, vfsResource(settings.owner, '/'), '*');
await acl.addUserRoles(settings.owner, ownerRole);
// Granted users
for (const entry of settings.acl) {
const resourcePath = entry.path ?? '/';
const resource = vfsResource(settings.owner, resourcePath);
const grantRole = `vfs-grant:${settings.owner}:${entry.userId}:${resourcePath}`;
await acl.allow(grantRole, resource, entry.permissions);
await acl.addUserRoles(entry.userId, grantRole);
}
return settings;
}

View File

@ -0,0 +1,7 @@
[
{
"name": "root",
"type": "fs",
"path": "./tests/vfs"
}
]

View File

@ -0,0 +1,226 @@
/**
* VFS ACL Real filesystem e2e test
*
* Uses AclVfsClient to perform real file I/O (write, read, mkdir, delete)
* and verifies ACL enforcement at each path level.
*
* Folder structure:
* tests/vfs/root/<ownerId>/
* docs/ (user ccc read+list, user aaa read+list from /)
* shared/ (user ccc read+write+list+mkdir+delete)
* private/ (only owner)
*
* Real files are written to a _test_sandbox/ subfolder within each dir
* and cleaned up after the test run.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { resolve, join } from 'node:path';
import { existsSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
import { Acl } from '../src/Acl.js';
import { MemoryBackend } from '../src/data/MemoryBackend.js';
import { loadVfsSettings } from '../src/vfs/vfs-acl.js';
import { AclVfsClient } from '../src/vfs/AclVfsClient.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OWNER = '3bb4cfbf-318b-44d3-a9d3-35680e738421';
const READ_ONLY = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; // read+list on /
const FULL_SHARED = 'cccccccc-4444-5555-6666-dddddddddddd'; // full on /shared, read on /docs
const STRANGER = '99999999-0000-0000-0000-ffffffffffff';
const VFS_ROOT = resolve(import.meta.dirname!, 'vfs/root');
const USER_DIR = join(VFS_ROOT, OWNER);
const SANDBOX = '_test_sandbox';
function client(acl: Acl, callerId: string): AclVfsClient {
return new AclVfsClient(acl, OWNER, callerId, { root: USER_DIR });
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
describe('VFS ACL — real filesystem e2e', () => {
let acl: Acl;
beforeAll(async () => {
acl = new Acl(new MemoryBackend());
await loadVfsSettings(acl, USER_DIR);
// Create sandbox dirs for write tests
for (const sub of ['shared', 'docs', 'private', '']) {
const p = join(USER_DIR, sub, SANDBOX);
if (!existsSync(p)) mkdirSync(p, { recursive: true });
}
});
afterAll(() => {
for (const sub of ['shared', 'docs', 'private', '']) {
const p = join(USER_DIR, sub, SANDBOX);
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
}
});
// =====================================================================
// Owner — full real ops on any path
// =====================================================================
describe('owner — real FS ops', () => {
it('can list root directory', async () => {
const c = client(acl, OWNER);
const entries = await c.readdir('.');
expect(entries.length).toBeGreaterThan(0);
});
it('can write and read a file in /private', async () => {
const c = client(acl, OWNER);
await c.writefile(`private/${SANDBOX}/owner.txt`, 'owner wrote this');
const content = readFileSync(join(USER_DIR, 'private', SANDBOX, 'owner.txt'), 'utf8');
expect(content).toBe('owner wrote this');
});
it('can create a sub-directory in /docs', async () => {
const c = client(acl, OWNER);
const dir = `docs/${SANDBOX}/owner-dir`;
await c.mkdir(dir);
expect(existsSync(join(USER_DIR, dir))).toBe(true);
});
it('can delete a file in /shared', async () => {
const c = client(acl, OWNER);
await c.writefile(`shared/${SANDBOX}/del.txt`, 'bye');
await c.rmfile(`shared/${SANDBOX}/del.txt`);
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'del.txt'))).toBe(false);
});
});
// =====================================================================
// Read-only user — read+list on "/" → can read everywhere, write nowhere
// =====================================================================
describe('read-only user — real FS ops', () => {
it('can list /docs (inherits from /)', async () => {
const c = client(acl, READ_ONLY);
const entries = await c.readdir('docs');
expect(entries.map(e => e.name)).toContain('readme.txt');
});
it('can check file existence in /shared', async () => {
const c = client(acl, READ_ONLY);
expect(await c.exists('shared/data.txt')).toBe(true);
});
it('CANNOT write a file → EACCES', async () => {
const c = client(acl, READ_ONLY);
await expect(c.writefile(`shared/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT create a directory → EACCES', async () => {
const c = client(acl, READ_ONLY);
await expect(c.mkdir(`docs/${SANDBOX}/nope`)).rejects.toThrow('EACCES');
});
it('CANNOT delete a file → EACCES', async () => {
const c = client(acl, READ_ONLY);
await expect(c.rmfile('docs/readme.txt')).rejects.toThrow('EACCES');
});
});
// =====================================================================
// Full-shared user — full on /shared, read on /docs, nothing on /private
// =====================================================================
describe('full-shared user — real FS ops', () => {
it('can write a file in /shared', async () => {
const c = client(acl, FULL_SHARED);
await c.writefile(`shared/${SANDBOX}/granted.txt`, 'shared write');
const content = readFileSync(join(USER_DIR, 'shared', SANDBOX, 'granted.txt'), 'utf8');
expect(content).toBe('shared write');
});
it('can list /shared', async () => {
const c = client(acl, FULL_SHARED);
const entries = await c.readdir(`shared/${SANDBOX}`);
expect(entries.map(e => e.name)).toContain('granted.txt');
});
it('can create directory in /shared', async () => {
const c = client(acl, FULL_SHARED);
await c.mkdir(`shared/${SANDBOX}/granted-dir`);
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'granted-dir'))).toBe(true);
});
it('can delete a file in /shared', async () => {
const c = client(acl, FULL_SHARED);
await c.writefile(`shared/${SANDBOX}/temp.txt`, 'tmp');
await c.rmfile(`shared/${SANDBOX}/temp.txt`);
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'temp.txt'))).toBe(false);
});
it('can list /docs (read+list)', async () => {
const c = client(acl, FULL_SHARED);
const entries = await c.readdir('docs');
expect(entries.map(e => e.name)).toContain('readme.txt');
});
it('CANNOT write in /docs → EACCES', async () => {
const c = client(acl, FULL_SHARED);
await expect(c.writefile(`docs/${SANDBOX}/x.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT list /private → EACCES', async () => {
const c = client(acl, FULL_SHARED);
await expect(c.readdir('private')).rejects.toThrow('EACCES');
});
it('CANNOT list root → EACCES', async () => {
const c = client(acl, FULL_SHARED);
await expect(c.readdir('.')).rejects.toThrow('EACCES');
});
it('CANNOT rename in /shared → EACCES', async () => {
const c = client(acl, FULL_SHARED);
await expect(c.rename(`shared/${SANDBOX}/granted.txt`, `shared/${SANDBOX}/renamed.txt`))
.rejects.toThrow('EACCES');
});
it('CANNOT copy in /shared → EACCES', async () => {
const c = client(acl, FULL_SHARED);
await expect(c.copy(`shared/${SANDBOX}/granted.txt`, `shared/${SANDBOX}/copied.txt`))
.rejects.toThrow('EACCES');
});
});
// =====================================================================
// Stranger — blocked everywhere
// =====================================================================
describe('stranger — everything blocked', () => {
it('CANNOT readdir /shared → EACCES', async () => {
const c = client(acl, STRANGER);
await expect(c.readdir('shared')).rejects.toThrow('EACCES');
});
it('CANNOT exists /docs → EACCES', async () => {
const c = client(acl, STRANGER);
await expect(c.exists('docs/readme.txt')).rejects.toThrow('EACCES');
});
it('CANNOT writefile /shared → EACCES', async () => {
const c = client(acl, STRANGER);
await expect(c.writefile(`shared/${SANDBOX}/x.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT mkdir /private → EACCES', async () => {
const c = client(acl, STRANGER);
await expect(c.mkdir(`private/${SANDBOX}/x`)).rejects.toThrow('EACCES');
});
it('CANNOT rmfile /docs → EACCES', async () => {
const c = client(acl, STRANGER);
await expect(c.rmfile('docs/readme.txt')).rejects.toThrow('EACCES');
});
});
});

View File

@ -0,0 +1,290 @@
/**
* VFS ACL Per-path e2e test with real filesystem operations
*
* Folder structure under tests/vfs/root/<ownerId>/:
*
* vfs-settings.json ACL config
* docs/
* readme.txt "hello docs"
* shared/
* data.txt "shared file"
* private/
* secret.txt "top secret"
*
* Settings:
* - user aaa... read+list on "/" (global)
* - user ccc... read+write+list+mkdir+delete on "/shared"
* - user ccc... read+list on "/docs"
* - nobody gets access to "/private" except the owner
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { resolve, join } from 'node:path';
import { existsSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
import { Acl } from '../src/Acl.js';
import { MemoryBackend } from '../src/data/MemoryBackend.js';
import { loadVfsSettings, resourceChain, vfsResource } from '../src/vfs/vfs-acl.js';
import { AclVfsClient } from '../src/vfs/AclVfsClient.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OWNER = '3bb4cfbf-318b-44d3-a9d3-35680e738421';
const READER = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; // read+list on /
const COLLAB = 'cccccccc-4444-5555-6666-dddddddddddd'; // full on /shared, read on /docs
const NOBODY = '99999999-0000-0000-0000-ffffffffffff';
const VFS_ROOT = resolve(import.meta.dirname!, 'vfs/root');
const USER_DIR = join(VFS_ROOT, OWNER);
const SANDBOX = '_path_sandbox';
function client(acl: Acl, callerId: string): AclVfsClient {
return new AclVfsClient(acl, OWNER, callerId, { root: USER_DIR });
}
// ---------------------------------------------------------------------------
// Unit: resourceChain
// ---------------------------------------------------------------------------
describe('resourceChain helper', () => {
it('builds chain from deep path to root', () => {
const chain = resourceChain('x', 'a/b/c.txt');
expect(chain).toEqual([
'vfs:x:/a/b/c.txt',
'vfs:x:/a/b',
'vfs:x:/a',
'vfs:x:/',
]);
});
it('handles root path', () => {
expect(resourceChain('x', '')).toEqual(['vfs:x:/']);
expect(resourceChain('x', '.')).toEqual(['vfs:x:/.', 'vfs:x:/']);
expect(resourceChain('x', '/')).toEqual(['vfs:x:/']);
});
it('normalises backslashes and trailing slashes', () => {
const chain = resourceChain('x', 'a\\b/');
expect(chain).toEqual(['vfs:x:/a/b', 'vfs:x:/a', 'vfs:x:/']);
});
});
// ---------------------------------------------------------------------------
// Real filesystem tests
// ---------------------------------------------------------------------------
describe('VFS ACL — per-path e2e', () => {
let acl: Acl;
beforeAll(async () => {
acl = new Acl(new MemoryBackend());
await loadVfsSettings(acl, USER_DIR);
// Create sandbox dirs for write tests
for (const sub of ['shared', 'docs', 'private']) {
const p = join(USER_DIR, sub, SANDBOX);
if (!existsSync(p)) mkdirSync(p, { recursive: true });
}
});
afterAll(() => {
for (const sub of ['shared', 'docs', 'private']) {
const p = join(USER_DIR, sub, SANDBOX);
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
}
});
// =====================================================================
// OWNER — wildcard on "/" → full access everywhere
// =====================================================================
describe('owner — wildcard on every path', () => {
it('can list /docs', async () => {
const c = client(acl, OWNER);
const entries = await c.readdir('docs');
expect(entries.map(e => e.name)).toContain('readme.txt');
});
it('can write inside /private', async () => {
const c = client(acl, OWNER);
await c.writefile(`private/${SANDBOX}/owner.txt`, 'ok');
expect(readFileSync(join(USER_DIR, 'private', SANDBOX, 'owner.txt'), 'utf8')).toBe('ok');
});
it('can delete inside /private', async () => {
const c = client(acl, OWNER);
await c.writefile(`private/${SANDBOX}/del.txt`, 'bye');
await c.rmfile(`private/${SANDBOX}/del.txt`);
expect(existsSync(join(USER_DIR, 'private', SANDBOX, 'del.txt'))).toBe(false);
});
});
// =====================================================================
// READER — read+list on "/" → can read everything, write nothing
// =====================================================================
describe('reader (read+list on /) — global read-only', () => {
it('can list /docs', async () => {
const c = client(acl, READER);
const entries = await c.readdir('docs');
expect(entries.map(e => e.name)).toContain('readme.txt');
});
it('can list /shared', async () => {
const c = client(acl, READER);
const entries = await c.readdir('shared');
expect(entries.length).toBeGreaterThan(0);
});
it('can check existence in /private (inherits read from /)', async () => {
const c = client(acl, READER);
const exists = await c.exists('private/secret.txt');
expect(exists).toBe(true);
});
it('CANNOT write in /docs', async () => {
const c = client(acl, READER);
await expect(c.writefile(`docs/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT write in /shared', async () => {
const c = client(acl, READER);
await expect(c.writefile(`shared/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT mkdir in /private', async () => {
const c = client(acl, READER);
await expect(c.mkdir(`private/${SANDBOX}/nope`)).rejects.toThrow('EACCES');
});
it('CANNOT delete in /private', async () => {
const c = client(acl, READER);
await expect(c.rmfile('private/secret.txt')).rejects.toThrow('EACCES');
});
});
// =====================================================================
// COLLAB — full on "/shared", read+list on "/docs", nothing on "/private"
// =====================================================================
describe('collaborator — per-folder permissions', () => {
// --- /shared — full access ---
it('can list /shared', async () => {
const c = client(acl, COLLAB);
const entries = await c.readdir('shared');
expect(entries.map(e => e.name)).toContain('data.txt');
});
it('can write inside /shared', async () => {
const c = client(acl, COLLAB);
await c.writefile(`shared/${SANDBOX}/collab.txt`, 'hi from collab');
expect(readFileSync(join(USER_DIR, 'shared', SANDBOX, 'collab.txt'), 'utf8')).toBe('hi from collab');
});
it('can mkdir inside /shared', async () => {
const c = client(acl, COLLAB);
await c.mkdir(`shared/${SANDBOX}/newdir`);
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'newdir'))).toBe(true);
});
it('can delete inside /shared', async () => {
const c = client(acl, COLLAB);
await c.writefile(`shared/${SANDBOX}/temp.txt`, 'tmp');
await c.rmfile(`shared/${SANDBOX}/temp.txt`);
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'temp.txt'))).toBe(false);
});
it('can read nested path /shared/sub/deep (inherits from /shared)', async () => {
const c = client(acl, COLLAB);
// Write as owner first
const ownerClient = client(acl, OWNER);
await ownerClient.writefile(`shared/${SANDBOX}/deep.txt`, 'deep content');
// Collab reads
const exists = await c.exists(`shared/${SANDBOX}/deep.txt`);
expect(exists).toBe(true);
});
// --- /docs — read + list only ---
it('can list /docs', async () => {
const c = client(acl, COLLAB);
const entries = await c.readdir('docs');
expect(entries.map(e => e.name)).toContain('readme.txt');
});
it('can check existence in /docs', async () => {
const c = client(acl, COLLAB);
expect(await c.exists('docs/readme.txt')).toBe(true);
});
it('CANNOT write in /docs', async () => {
const c = client(acl, COLLAB);
await expect(c.writefile(`docs/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT delete in /docs', async () => {
const c = client(acl, COLLAB);
await expect(c.rmfile('docs/readme.txt')).rejects.toThrow('EACCES');
});
it('CANNOT mkdir in /docs', async () => {
const c = client(acl, COLLAB);
await expect(c.mkdir(`docs/${SANDBOX}/nope`)).rejects.toThrow('EACCES');
});
// --- /private — no access at all ---
it('CANNOT list /private', async () => {
const c = client(acl, COLLAB);
await expect(c.readdir('private')).rejects.toThrow('EACCES');
});
it('CANNOT read /private', async () => {
const c = client(acl, COLLAB);
await expect(c.exists('private/secret.txt')).rejects.toThrow('EACCES');
});
it('CANNOT write in /private', async () => {
const c = client(acl, COLLAB);
await expect(c.writefile(`private/${SANDBOX}/x.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT mkdir in /private', async () => {
const c = client(acl, COLLAB);
await expect(c.mkdir(`private/${SANDBOX}/x`)).rejects.toThrow('EACCES');
});
// --- root "/" — no access (no root grant for collab) ---
it('CANNOT list root "/"', async () => {
const c = client(acl, COLLAB);
await expect(c.readdir('.')).rejects.toThrow('EACCES');
});
});
// =====================================================================
// NOBODY — zero grants → blocked everywhere
// =====================================================================
describe('stranger — blocked everywhere', () => {
const paths = ['docs', 'shared', 'private', '.'] as const;
for (const p of paths) {
it(`CANNOT list /${p}`, async () => {
const c = client(acl, NOBODY);
await expect(c.readdir(p)).rejects.toThrow('EACCES');
});
}
it('CANNOT read /shared/data.txt', async () => {
const c = client(acl, NOBODY);
await expect(c.exists('shared/data.txt')).rejects.toThrow('EACCES');
});
it('CANNOT write /shared/x.txt', async () => {
const c = client(acl, NOBODY);
await expect(c.writefile(`shared/${SANDBOX}/x.txt`, 'x')).rejects.toThrow('EACCES');
});
});
});

View File

@ -0,0 +1,182 @@
/**
* VFS ACL End-to-end test (permission-boundary checks)
*
* Verifies raw ACL permission checks against path-scoped resources.
*
* Uses the real vfs-settings.json fixture under tests/vfs/root/<ownerId>/:
* - Owner wildcard on "/" (entire tree)
* - User aaa read+list on "/" (global)
* - User ccc read+write+list+mkdir+delete on "/shared"
* - User ccc read+list on "/docs"
* - Stranger nothing
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { resolve } from 'node:path';
import { Acl } from '../src/Acl.js';
import { MemoryBackend } from '../src/data/MemoryBackend.js';
import { loadVfsSettings, vfsResource } from '../src/vfs/vfs-acl.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OWNER_ID = '3bb4cfbf-318b-44d3-a9d3-35680e738421';
const READ_ONLY_USER = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb';
const FULL_GRANT_USER = 'cccccccc-4444-5555-6666-dddddddddddd';
const STRANGER_USER = '99999999-0000-0000-0000-ffffffffffff';
// Path-scoped resources
const ROOT = vfsResource(OWNER_ID, '/');
const SHARED = vfsResource(OWNER_ID, '/shared');
const DOCS = vfsResource(OWNER_ID, '/docs');
const PRIVATE = vfsResource(OWNER_ID, '/private');
const USER_DIR = resolve(import.meta.dirname!, 'vfs/root', OWNER_ID);
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('VFS ACL — e2e', () => {
let acl: Acl;
beforeAll(async () => {
acl = new Acl(new MemoryBackend());
const settings = await loadVfsSettings(acl, USER_DIR);
expect(settings).not.toBeNull();
expect(settings!.owner).toBe(OWNER_ID);
});
// =====================================================================
// Owner — wildcard on "/" → full access on every resource
// =====================================================================
describe(`owner (${OWNER_ID})`, () => {
it('can do anything on /', async () => {
expect(await acl.isAllowed(OWNER_ID, ROOT, 'read')).toBe(true);
expect(await acl.isAllowed(OWNER_ID, ROOT, 'write')).toBe(true);
expect(await acl.isAllowed(OWNER_ID, ROOT, 'list')).toBe(true);
expect(await acl.isAllowed(OWNER_ID, ROOT, 'mkdir')).toBe(true);
expect(await acl.isAllowed(OWNER_ID, ROOT, 'delete')).toBe(true);
expect(await acl.isAllowed(OWNER_ID, ROOT, 'rename')).toBe(true);
expect(await acl.isAllowed(OWNER_ID, ROOT, 'copy')).toBe(true);
});
});
// =====================================================================
// Read-only user — read + list on "/" (global)
// =====================================================================
describe(`read-only user (${READ_ONLY_USER})`, () => {
it('can read on /', async () => {
expect(await acl.isAllowed(READ_ONLY_USER, ROOT, 'read')).toBe(true);
});
it('can list on /', async () => {
expect(await acl.isAllowed(READ_ONLY_USER, ROOT, 'list')).toBe(true);
});
it('CANNOT write on /', async () => {
expect(await acl.isAllowed(READ_ONLY_USER, ROOT, 'write')).toBe(false);
});
it('CANNOT mkdir on /', async () => {
expect(await acl.isAllowed(READ_ONLY_USER, ROOT, 'mkdir')).toBe(false);
});
it('CANNOT delete on /', async () => {
expect(await acl.isAllowed(READ_ONLY_USER, ROOT, 'delete')).toBe(false);
});
it('CANNOT rename on /', async () => {
expect(await acl.isAllowed(READ_ONLY_USER, ROOT, 'rename')).toBe(false);
});
});
// =====================================================================
// Full-grant user — per-path grants
// /shared → read, write, list, mkdir, delete
// /docs → read, list
// / → nothing
// =====================================================================
describe(`full-grant user (${FULL_GRANT_USER})`, () => {
it('can read on /shared', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, SHARED, 'read')).toBe(true);
});
it('can write on /shared', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, SHARED, 'write')).toBe(true);
});
it('can list on /shared', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, SHARED, 'list')).toBe(true);
});
it('can mkdir on /shared', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, SHARED, 'mkdir')).toBe(true);
});
it('can delete on /shared', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, SHARED, 'delete')).toBe(true);
});
it('CANNOT rename on /shared (not granted)', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, SHARED, 'rename')).toBe(false);
});
it('CANNOT copy on /shared (not granted)', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, SHARED, 'copy')).toBe(false);
});
// /docs — read + list only
it('can read on /docs', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, DOCS, 'read')).toBe(true);
});
it('can list on /docs', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, DOCS, 'list')).toBe(true);
});
it('CANNOT write on /docs', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, DOCS, 'write')).toBe(false);
});
// /private — no grant at all
it('CANNOT read on /private', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, PRIVATE, 'read')).toBe(false);
});
it('CANNOT list on /private', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, PRIVATE, 'list')).toBe(false);
});
// root "/" — no grant
it('CANNOT read on / (no root grant)', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, ROOT, 'read')).toBe(false);
});
});
// =====================================================================
// Stranger — no access at all
// =====================================================================
describe(`stranger (${STRANGER_USER})`, () => {
for (const res of [ROOT, SHARED, DOCS, PRIVATE]) {
it(`CANNOT read on ${res}`, async () => {
expect(await acl.isAllowed(STRANGER_USER, res, 'read')).toBe(false);
});
}
it('CANNOT write on /shared', async () => {
expect(await acl.isAllowed(STRANGER_USER, SHARED, 'write')).toBe(false);
});
it('CANNOT list on /docs', async () => {
expect(await acl.isAllowed(STRANGER_USER, DOCS, 'list')).toBe(false);
});
});
});

View File

@ -0,0 +1,2 @@
shared file - cuz sharing is caring :)

View File

@ -0,0 +1,32 @@
{
"owner": "3bb4cfbf-318b-44d3-a9d3-35680e738421",
"acl": [
{
"userId": "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb",
"path": "/",
"permissions": [
"read",
"list"
]
},
{
"userId": "cccccccc-4444-5555-6666-dddddddddddd",
"path": "/shared",
"permissions": [
"read",
"write",
"list",
"mkdir",
"delete"
]
},
{
"userId": "cccccccc-4444-5555-6666-dddddddddddd",
"path": "/docs",
"permissions": [
"read",
"list"
]
}
]
}