From 952d125f4835e1a7104a012cc5a480f2eb28f5bc Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 17 Feb 2026 22:32:32 +0100 Subject: [PATCH] ACL 2/2 --- packages/acl/README.md | 337 ++++++++++++++ packages/acl/docs/postgres-rls-vfs.md | 412 ++++++++++++++++++ packages/acl/docs/supabase-rls-vfs.md | 290 ++++++++++++ packages/acl/src/Acl.ts | 372 ++++++++++++++++ packages/acl/src/acl.test.ts | 264 +++++++++++ packages/acl/src/data/FileBackend.ts | 47 ++ packages/acl/src/data/MemoryBackend.ts | 147 +++++++ packages/acl/src/index.ts | 23 + packages/acl/src/vfs/AclVfsClient.ts | 128 ++++++ packages/acl/src/vfs/DecoratedVfsClient.ts | 144 ++++++ packages/acl/src/vfs/fs/Local.ts | 277 ++++++++++++ packages/acl/src/vfs/fs/Resource.ts | 71 +++ packages/acl/src/vfs/fs/VFS.ts | 142 ++++++ packages/acl/src/vfs/fs/index.ts | 4 + packages/acl/src/vfs/path-sanitizer.ts | 252 +++++++++++ packages/acl/src/vfs/vfs-acl.ts | 102 +++++ packages/acl/tests/config/vfs.json | 7 + packages/acl/tests/vfs-acl-fs.e2e.test.ts | 226 ++++++++++ packages/acl/tests/vfs-acl-paths.e2e.test.ts | 290 ++++++++++++ packages/acl/tests/vfs-acl.e2e.test.ts | 182 ++++++++ .../docs/readme.txt | 1 + .../private/secret.txt | 1 + .../shared/data.txt | 2 + .../vfs-settings.json | 32 ++ 24 files changed, 3753 insertions(+) create mode 100644 packages/acl/README.md create mode 100644 packages/acl/docs/postgres-rls-vfs.md create mode 100644 packages/acl/docs/supabase-rls-vfs.md create mode 100644 packages/acl/src/Acl.ts create mode 100644 packages/acl/src/acl.test.ts create mode 100644 packages/acl/src/data/FileBackend.ts create mode 100644 packages/acl/src/data/MemoryBackend.ts create mode 100644 packages/acl/src/index.ts create mode 100644 packages/acl/src/vfs/AclVfsClient.ts create mode 100644 packages/acl/src/vfs/DecoratedVfsClient.ts create mode 100644 packages/acl/src/vfs/fs/Local.ts create mode 100644 packages/acl/src/vfs/fs/Resource.ts create mode 100644 packages/acl/src/vfs/fs/VFS.ts create mode 100644 packages/acl/src/vfs/fs/index.ts create mode 100644 packages/acl/src/vfs/path-sanitizer.ts create mode 100644 packages/acl/src/vfs/vfs-acl.ts create mode 100644 packages/acl/tests/config/vfs.json create mode 100644 packages/acl/tests/vfs-acl-fs.e2e.test.ts create mode 100644 packages/acl/tests/vfs-acl-paths.e2e.test.ts create mode 100644 packages/acl/tests/vfs-acl.e2e.test.ts create mode 100644 packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/docs/readme.txt create mode 100644 packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/private/secret.txt create mode 100644 packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/shared/data.txt create mode 100644 packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/vfs-settings.json diff --git a/packages/acl/README.md b/packages/acl/README.md new file mode 100644 index 00000000..730d92f5 --- /dev/null +++ b/packages/acl/README.md @@ -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 { + begin(): RedisTx { /* ... */ } + async end(tx: RedisTx): Promise { /* ... */ } + async clean(): Promise { /* ... */ } + async get(bucket: string, key: Value): Promise { /* ... */ } + async union(bucket: string, keys: Value[]): Promise { /* ... */ } + async unions(buckets: string[], keys: Value[]): Promise> { /* ... */ } + 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 +``` diff --git a/packages/acl/docs/postgres-rls-vfs.md b/packages/acl/docs/postgres-rls-vfs.md new file mode 100644 index 00000000..4617b5ba --- /dev/null +++ b/packages/acl/docs/postgres-rls-vfs.md @@ -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 = ''; +``` + +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 { + const { rows } = await pool.query( + 'SELECT owner_id, grantee_id, resource_path, permissions FROM public.vfs_permissions', + ); + + // Group by owner + const byOwner = new Map(); + 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:: 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 { + 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( + '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. diff --git a/packages/acl/docs/supabase-rls-vfs.md b/packages/acl/docs/supabase-rls-vfs.md new file mode 100644 index 00000000..f2721f97 --- /dev/null +++ b/packages/acl/docs/supabase-rls-vfs.md @@ -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 { + 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(); + 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. diff --git a/packages/acl/src/Acl.ts b/packages/acl/src/Acl.ts new file mode 100644 index 00000000..9bde999f --- /dev/null +++ b/packages/acl/src/Acl.ts @@ -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(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(a: T[], b: T[]): T[] { + return [...new Set([...a, ...b])]; +} + +/** Items in `a` that are not in `b`. */ +function difference(a: T[], b: T[]): T[] { + const set = new Set(b); + return a.filter((x) => !set.has(x)); +} + +/** Intersection of `a` and `b`. */ +function intersect(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; + readonly #buckets: BucketNames; + readonly #logger: Logger | undefined; + + constructor(backend: IBackend, 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 { + // 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 { + 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 { + 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 { + 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 { + return this.#backend.get(this.#buckets.users, userId); + } + + async roleUsers(role: Value): Promise { + return this.#backend.get(this.#buckets.roles, role); + } + + async hasRole(userId: Value, role: string): Promise { + const roles = await this.userRoles(userId); + return roles.includes(role); + } + + // ------------------------------------------------------------------------- + // Role hierarchy + // ------------------------------------------------------------------------- + + async addRoleParents(role: string, parents: Values): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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> { + if (!userId) return {}; + + const res = toArray(resources) as string[]; + const roles = await this.userRoles(userId); + const result: Record = {}; + + for (const resource of res) { + result[resource] = await this.#resourcePermissions(roles, resource); + } + return result; + } + + async isAllowed(userId: Value, resource: string, permissions: Values): Promise { + 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 { + 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 | 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 | string[]> { + const resources = await this.#rolesResources(roles); + + if (!permissions) { + const result: Record = {}; + 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 { + return this.#backend.union(this.#buckets.parents, roles); + } + + async #allRoles(roleNames: string[]): Promise { + 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 { + const roles = await this.userRoles(userId); + if (roles.length > 0) { + return this.#allRoles(roles); + } + return []; + } + + async #rolesResources(roles: string[]): Promise { + 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 { + 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 { + 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; + } +} diff --git a/packages/acl/src/acl.test.ts b/packages/acl/src/acl.test.ts new file mode 100644 index 00000000..209534a1 --- /dev/null +++ b/packages/acl/src/acl.test.ts @@ -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; + 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'); + }); + }); +}); diff --git a/packages/acl/src/data/FileBackend.ts b/packages/acl/src/data/FileBackend.ts new file mode 100644 index 00000000..7dfae458 --- /dev/null +++ b/packages/acl/src/data/FileBackend.ts @@ -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 }); + } +} diff --git a/packages/acl/src/data/MemoryBackend.ts b/packages/acl/src/data/MemoryBackend.ts new file mode 100644 index 00000000..bc1f485e --- /dev/null +++ b/packages/acl/src/data/MemoryBackend.ts @@ -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>; + +// --------------------------------------------------------------------------- +// 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 { + #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 { + for (const fn of transaction) { + fn(); + } + } + + async clean(): Promise { + this.#buckets = {}; + } + + // -- Reads ---------------------------------------------------------------- + + async get(bucket: string, key: Value): Promise { + return this.#buckets[bucket]?.[key] ?? []; + } + + async union(bucket: string, keys: Value[]): Promise { + 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> { + const result: Record = {}; + 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 | 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; + } +} diff --git a/packages/acl/src/index.ts b/packages/acl/src/index.ts new file mode 100644 index 00000000..8b784005 --- /dev/null +++ b/packages/acl/src/index.ts @@ -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'; diff --git a/packages/acl/src/vfs/AclVfsClient.ts b/packages/acl/src/vfs/AclVfsClient.ts new file mode 100644 index 00000000..1f1ec2d6 --- /dev/null +++ b/packages/acl/src/vfs/AclVfsClient.ts @@ -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 { + 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 { + await this.#guard('read', path); + return this.#local.stat(path); + } + + async readdir(path: string): Promise { + await this.#guard('list', path); + return this.#local.readdir(path); + } + + async readfile(path: string, options?: Record): Promise<{ stream: ReadStream; meta: unknown }> { + await this.#guard('read', path); + return this.#local.readfile(path, options); + } + + async exists(path: string): Promise { + await this.#guard('read', path); + return this.#local.exists(path); + } + + // ── Write operations ──────────────────────────────────────────── + + async writefile(path: string, content: string | Buffer, options?: Record): Promise { + await this.#guard('write', path); + return this.#local.writefile(path, content, options); + } + + async mkfile(path: string): Promise { + await this.#guard('write', path); + return this.#local.mkfile(path); + } + + async mkdir(path: string): Promise { + await this.#guard('mkdir', path); + return this.#local.mkdir(path, { recursive: true }); + } + + // ── Delete operations ─────────────────────────────────────────── + + async rmfile(path: string): Promise { + await this.#guard('delete', path); + return this.#local.rmfile(path); + } + + async rmdir(path: string): Promise { + await this.#guard('delete', path); + return this.#local.rmdir(path); + } + + // ── Move / Copy ───────────────────────────────────────────────── + + async rename(from: string, to: string): Promise { + await this.#guard('rename', from); + return this.#local.rename(from, to); + } + + async copy(from: string, to: string): Promise { + await this.#guard('copy', from); + return this.#local.copy(from, to); + } +} diff --git a/packages/acl/src/vfs/DecoratedVfsClient.ts b/packages/acl/src/vfs/DecoratedVfsClient.ts new file mode 100644 index 00000000..050cae56 --- /dev/null +++ b/packages/acl/src/vfs/DecoratedVfsClient.ts @@ -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 ( + target: (this: T, ...args: A) => Promise, + context: ClassMethodDecoratorContext Promise>, + ) { + const methodName = String(context.name); + + return async function (this: T, ...args: A): Promise { + 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 { + return this.local.stat(path); + } + + @aclGuard('list') + async readdir(path: string): Promise { + return this.local.readdir(path); + } + + @aclGuard('read') + async readfile(path: string, options?: Record): Promise<{ stream: ReadStream; meta: unknown }> { + return this.local.readfile(path, options); + } + + @aclGuard('read') + async exists(path: string): Promise { + return this.local.exists(path); + } + + // ── Write ─────────────────────────────────────────────────────── + + @aclGuard('write') + async writefile(path: string, content: string | Buffer, options?: Record): Promise { + return this.local.writefile(path, content, options); + } + + @aclGuard('write') + async mkfile(path: string): Promise { + return this.local.mkfile(path); + } + + @aclGuard('mkdir') + async mkdir(path: string): Promise { + return this.local.mkdir(path, { recursive: true }); + } + + // ── Delete ────────────────────────────────────────────────────── + + @aclGuard('delete') + async rmfile(path: string): Promise { + return this.local.rmfile(path); + } + + @aclGuard('delete') + async rmdir(path: string): Promise { + return this.local.rmdir(path); + } + + // ── Move / Copy ───────────────────────────────────────────────── + + @aclGuard('rename') + async rename(from: string, to: string): Promise { + return this.local.rename(from, to); + } + + @aclGuard('copy') + async copy(from: string, to: string): Promise { + return this.local.copy(from, to); + } +} diff --git a/packages/acl/src/vfs/fs/Local.ts b/packages/acl/src/vfs/fs/Local.ts new file mode 100644 index 00000000..d4722ba8 --- /dev/null +++ b/packages/acl/src/vfs/fs/Local.ts @@ -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 { + // 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 { + 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 { + 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 { + 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 { + 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 { + await this.writefile(path, '', options); + } + + async mkdir(path: string, options: any = {}): Promise { + const realp = await this.resolvePath(path); + await mkdir(realp, options); + } + + async mkdirP(path: string, options: any = {}): Promise { + const realp = await this.resolvePath(path, { checkSymlinks: false }); + await mkdir(realp, { ...options, recursive: true }); + } + + async rmfile(path: string, options?: any): Promise { + const realp = await this.resolvePath(path); + await rm(realp, { recursive: true, force: true }); + } + + async rmdir(path: string, options: any = {}): Promise { + const realp = await this.resolvePath(path); + await rm(realp, { recursive: true, force: true }); + } + + async rename(from: string, to: string, options: any = {}): Promise { + 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 { + const frompath = await this.resolvePath(from); + const topath = await this.resolvePath(to); + await cp(frompath, topath, { recursive: true }); + } + + exists(path: string): Promise { + 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) + + '"' + ); + } +} diff --git a/packages/acl/src/vfs/fs/Resource.ts b/packages/acl/src/vfs/fs/Resource.ts new file mode 100644 index 00000000..ac3bd88d --- /dev/null +++ b/packages/acl/src/vfs/fs/Resource.ts @@ -0,0 +1,71 @@ +// tslint:disable-next-line:interface-name +export interface Hash { + [ id: string ]: T; +} +// tslint:disable-next-line:interface-name +export interface List { + [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 = { new (): T } | Function; +/** + * Same as Partial but goes deeper and makes Partial all its properties and sub-properties. + */ +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; + +export interface IDelimitter { + begin: '%'; + end: '%'; +} + + +export enum EResourceType { + JS_HEADER_INCLUDE = 'JS-HEADER-INCLUDE', + JS_HEADER_SCRIPT_TAG = 'JS-HEADER-SCRIPT-TAG', + CSS = 'CSS', + FILE_PROXY = '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; diff --git a/packages/acl/src/vfs/fs/VFS.ts b/packages/acl/src/vfs/fs/VFS.ts new file mode 100644 index 00000000..30511ede --- /dev/null +++ b/packages/acl/src/vfs/fs/VFS.ts @@ -0,0 +1,142 @@ + +/** + * Node types + * + * @export + * @enum {string} + */ +export enum ENodeType { + FILE = 'file', + DIR = 'dir', + SYMLINK = 'symlink', + OTHER = 'other', + BLOCK = '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 }; + } +} + + diff --git a/packages/acl/src/vfs/fs/index.ts b/packages/acl/src/vfs/fs/index.ts new file mode 100644 index 00000000..63757c20 --- /dev/null +++ b/packages/acl/src/vfs/fs/index.ts @@ -0,0 +1,4 @@ +export * from './Local.js'; +export * from './VFS.js'; + + diff --git a/packages/acl/src/vfs/path-sanitizer.ts b/packages/acl/src/vfs/path-sanitizer.ts new file mode 100644 index 00000000..819d302c --- /dev/null +++ b/packages/acl/src/vfs/path-sanitizer.ts @@ -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; +} diff --git a/packages/acl/src/vfs/vfs-acl.ts b/packages/acl/src/vfs/vfs-acl.ts new file mode 100644 index 00000000..321cfb9e --- /dev/null +++ b/packages/acl/src/vfs/vfs-acl.ts @@ -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::` — 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 { + 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; +} diff --git a/packages/acl/tests/config/vfs.json b/packages/acl/tests/config/vfs.json new file mode 100644 index 00000000..550179b3 --- /dev/null +++ b/packages/acl/tests/config/vfs.json @@ -0,0 +1,7 @@ +[ + { + "name": "root", + "type": "fs", + "path": "./tests/vfs" + } +] \ No newline at end of file diff --git a/packages/acl/tests/vfs-acl-fs.e2e.test.ts b/packages/acl/tests/vfs-acl-fs.e2e.test.ts new file mode 100644 index 00000000..b7789651 --- /dev/null +++ b/packages/acl/tests/vfs-acl-fs.e2e.test.ts @@ -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// + * ├── 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'); + }); + }); +}); diff --git a/packages/acl/tests/vfs-acl-paths.e2e.test.ts b/packages/acl/tests/vfs-acl-paths.e2e.test.ts new file mode 100644 index 00000000..019304bd --- /dev/null +++ b/packages/acl/tests/vfs-acl-paths.e2e.test.ts @@ -0,0 +1,290 @@ +/** + * VFS ACL — Per-path e2e test with real filesystem operations + * + * Folder structure under tests/vfs/root//: + * + * ├── 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'); + }); + }); +}); diff --git a/packages/acl/tests/vfs-acl.e2e.test.ts b/packages/acl/tests/vfs-acl.e2e.test.ts new file mode 100644 index 00000000..3e5dd0be --- /dev/null +++ b/packages/acl/tests/vfs-acl.e2e.test.ts @@ -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//: + * - 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); + }); + }); +}); diff --git a/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/docs/readme.txt b/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/docs/readme.txt new file mode 100644 index 00000000..feaf5aad --- /dev/null +++ b/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/docs/readme.txt @@ -0,0 +1 @@ +hello docs diff --git a/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/private/secret.txt b/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/private/secret.txt new file mode 100644 index 00000000..9ed557ff --- /dev/null +++ b/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/private/secret.txt @@ -0,0 +1 @@ +top secret diff --git a/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/shared/data.txt b/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/shared/data.txt new file mode 100644 index 00000000..fcdf6b8a --- /dev/null +++ b/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/shared/data.txt @@ -0,0 +1,2 @@ +shared file - cuz sharing is caring :) + diff --git a/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/vfs-settings.json b/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/vfs-settings.json new file mode 100644 index 00000000..1d435069 --- /dev/null +++ b/packages/acl/tests/vfs/root/3bb4cfbf-318b-44d3-a9d3-35680e738421/vfs-settings.json @@ -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" + ] + } + ] +} \ No newline at end of file