ACL 2/2
This commit is contained in:
parent
3fc0893e0c
commit
952d125f48
337
packages/acl/README.md
Normal file
337
packages/acl/README.md
Normal 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
|
||||
```
|
||||
412
packages/acl/docs/postgres-rls-vfs.md
Normal file
412
packages/acl/docs/postgres-rls-vfs.md
Normal 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.
|
||||
290
packages/acl/docs/supabase-rls-vfs.md
Normal file
290
packages/acl/docs/supabase-rls-vfs.md
Normal 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
372
packages/acl/src/Acl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
264
packages/acl/src/acl.test.ts
Normal file
264
packages/acl/src/acl.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
47
packages/acl/src/data/FileBackend.ts
Normal file
47
packages/acl/src/data/FileBackend.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
147
packages/acl/src/data/MemoryBackend.ts
Normal file
147
packages/acl/src/data/MemoryBackend.ts
Normal 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
23
packages/acl/src/index.ts
Normal 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';
|
||||
128
packages/acl/src/vfs/AclVfsClient.ts
Normal file
128
packages/acl/src/vfs/AclVfsClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
144
packages/acl/src/vfs/DecoratedVfsClient.ts
Normal file
144
packages/acl/src/vfs/DecoratedVfsClient.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
277
packages/acl/src/vfs/fs/Local.ts
Normal file
277
packages/acl/src/vfs/fs/Local.ts
Normal 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) +
|
||||
'"'
|
||||
);
|
||||
}
|
||||
}
|
||||
71
packages/acl/src/vfs/fs/Resource.ts
Normal file
71
packages/acl/src/vfs/fs/Resource.ts
Normal 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;
|
||||
142
packages/acl/src/vfs/fs/VFS.ts
Normal file
142
packages/acl/src/vfs/fs/VFS.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
packages/acl/src/vfs/fs/index.ts
Normal file
4
packages/acl/src/vfs/fs/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './Local.js';
|
||||
export * from './VFS.js';
|
||||
|
||||
|
||||
252
packages/acl/src/vfs/path-sanitizer.ts
Normal file
252
packages/acl/src/vfs/path-sanitizer.ts
Normal 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;
|
||||
}
|
||||
102
packages/acl/src/vfs/vfs-acl.ts
Normal file
102
packages/acl/src/vfs/vfs-acl.ts
Normal 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;
|
||||
}
|
||||
7
packages/acl/tests/config/vfs.json
Normal file
7
packages/acl/tests/config/vfs.json
Normal file
@ -0,0 +1,7 @@
|
||||
[
|
||||
{
|
||||
"name": "root",
|
||||
"type": "fs",
|
||||
"path": "./tests/vfs"
|
||||
}
|
||||
]
|
||||
226
packages/acl/tests/vfs-acl-fs.e2e.test.ts
Normal file
226
packages/acl/tests/vfs-acl-fs.e2e.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
290
packages/acl/tests/vfs-acl-paths.e2e.test.ts
Normal file
290
packages/acl/tests/vfs-acl-paths.e2e.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
182
packages/acl/tests/vfs-acl.e2e.test.ts
Normal file
182
packages/acl/tests/vfs-acl.e2e.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
hello docs
|
||||
@ -0,0 +1 @@
|
||||
top secret
|
||||
@ -0,0 +1,2 @@
|
||||
shared file - cuz sharing is caring :)
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user