acl:groups, tests, async, lint

This commit is contained in:
lovebird 2026-02-17 23:33:57 +01:00
parent 952d125f48
commit 4d1eade6c0
55 changed files with 4804 additions and 1247 deletions

View File

@ -2,33 +2,8 @@
.deno
deno.lock
deno.json._decorators
package-lock.json
# Dependencies
node_modules/
# Build output
build/
out/
# Environment files
.env*
!src/.env/
!src/.env/*md
# Generated files
.dts
types/
.D_Store
.vscode/!settings.json
# Logs
*.Log
*.Log.*
docs-internal
systems/code-server-defaults
systems/workspace/kbot-docs
systems/.code-server/code-server-ipc.sock
systems/.code-server/User/workspaceStorage/
systems/code-server-defaults
systems/.code-server
tests/assets/
packages/kbot/systems/gptr/gpt-researcher

View File

@ -184,7 +184,7 @@ class RedisBackend implements IBackend<RedisTx> {
## 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.
Fine-grained, path-scoped access control for a virtual filesystem where each user owns a folder and can grant specific permissions to others — individually or via **groups**.
### How It Works
@ -193,20 +193,45 @@ Each user's VFS folder contains a `vfs-settings.json` that declares who can do w
```json
{
"owner": "3bb4cfbf-318b-44d3-a9d3-35680e738421",
"groups": [
{
"name": "team",
"members": ["aaaaaaaa-...", "cccccccc-..."]
},
{
"name": "viewers",
"members": ["dddddddd-...", "ffffffff-..."]
}
],
"acl": [
{
"userId": "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb",
"group": "team",
"path": "/shared",
"permissions": ["read", "write", "list", "mkdir", "delete"]
},
{
"group": "team",
"path": "/docs",
"permissions": ["read", "list"]
},
{
"userId": "cccccccc-4444-5555-6666-dddddddddddd",
"permissions": ["read", "write", "list", "mkdir", "delete"]
"group": "viewers",
"path": "/docs",
"permissions": ["read", "list"]
},
{
"userId": "ffffffff-...",
"path": "/shared",
"permissions": ["read", "list"]
}
]
}
```
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.
- **Groups** bundle users — grant once, apply to all members.
- **Direct user grants** coexist with groups for individual overrides.
- **Path scoping** — grants apply to a specific folder and its children (`/shared`, `/docs`).
- The **owner** always gets `*` on `/` (entire tree).
### Permission Model
@ -221,9 +246,20 @@ The [VFS ACL bridge](src/vfs/vfs-acl.ts) loads these settings and translates the
| `copy` | `copy` |
| `*` | All of the above — auto-granted to the folder owner |
### Setup Flow
### Path-Scoped Resolution
When `loadVfsSettings` reads a user's settings file, it creates roles and grants in the ACL:
When a user accesses `/shared/reports/q1.pdf`, the guard walks the **resource chain** upward:
```
vfs:<ownerId>:/shared/reports/q1.pdf ← most specific
vfs:<ownerId>:/shared/reports
vfs:<ownerId>:/shared ← team group matches here ✓
vfs:<ownerId>:/
```
Access is granted if **any** level allows the operation.
### Setup Flow
```mermaid
sequenceDiagram
@ -235,51 +271,23 @@ sequenceDiagram
Bridge->>Bridge: Read vfs-settings.json
Bridge->>ACL: allow owner role, resource, wildcard
Bridge->>ACL: addUserRoles owner, owner role
loop Each ACL entry
loop Each group ACL entry
Bridge->>ACL: allow group role, resource, permissions
loop Each group member
Bridge->>ACL: addUserRoles member, group role
end
end
loop Each direct ACL entry
Bridge->>ACL: allow grant role, resource, permissions
Bridge->>ACL: addUserRoles grantee, grant role
end
Bridge-->>App: Return settings
```
### 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';
import { Acl, MemoryBackend, AclVfsClient, loadVfsSettings } from '@polymech/acl';
// 1. Create ACL and load settings from the user's folder
const acl = new Acl(new MemoryBackend());
@ -288,43 +296,44 @@ 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 callerId = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; // team member
const client = new AclVfsClient(acl, ownerId, callerId, {
root: userDir,
});
// 3. Allowed — caller has "list" permission
const files = await client.readdir('.'); // ✓ works
// 3. Allowed — team has read+list on /docs
const files = await client.readdir('docs'); // ✓ works
const exists = await client.exists('docs/readme.txt'); // ✓ works
// 4. Allowed — caller has "read" permission
const exists = await client.exists('readme.txt'); // ✓ works
// 4. Allowed — team has full access on /shared
await client.writefile('shared/notes.txt', 'hello'); // ✓ works
await client.mkdir('shared/reports'); // ✓ works
// 5. Denied — caller lacks "write" permission
await client.writefile('hack.txt', '...'); // ✗ throws EACCES
// 5. Denied — team has no access on /private
await client.readdir('private'); // ✗ throws EACCES
await client.writefile('private/x.txt', '...'); // ✗ throws EACCES
// 6. Denied — caller lacks "mkdir" permission
await client.mkdir('new-folder'); // ✗ throws EACCES
// 6. Denied — team has read-only on /docs, no write
await client.writefile('docs/hack.txt', '...'); // ✗ 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
├── vfs/root/
│ ├── 3bb4cfbf-…/
│ │ ├── vfs-settings.json # Path-scoped direct grants
│ │ ├── docs/readme.txt
│ │ ├── shared/data.txt
│ │ └── private/secret.txt
│ └── groups-test/
│ └── vfs-settings.json # Group grants + mixed direct
├── vfs-acl.e2e.test.ts # 26 permission boundary tests
├── vfs-acl-fs.e2e.test.ts # 24 real filesystem tests
├── vfs-acl-paths.e2e.test.ts # 34 per-path nested tests
└── vfs-acl-groups.e2e.test.ts # 27 groups e2e tests
```
## Scripts
@ -332,6 +341,7 @@ npx vitest run tests/ src/acl.test.ts # 67 tests total
```bash
npm run build # Compile TypeScript
npm run dev # Watch mode
npm run test:core # Run functional tests
npm run test:core # Core ACL tests (23)
npm run test:all # Full suite (134 tests)
npm run lint # ESLint
```

27
packages/acl/dist-in/Acl.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
/**
* @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, AclResult, IBackend, IAcl, Value, Values } from './interfaces.js';
export declare class Acl implements IAcl {
#private;
constructor(backend: IBackend<unknown>, logger?: Logger, options?: AclOptions);
allow(rolesOrGrants: Values | AclGrant[], resources?: Values, permissions?: Values): Promise<AclResult>;
addUserRoles(userId: Value, roles: Values): Promise<AclResult>;
removeUserRoles(userId: Value, roles: Values): Promise<AclResult>;
userRoles(userId: Value): Promise<AclResult<string[]>>;
roleUsers(role: Value): Promise<AclResult<string[]>>;
hasRole(userId: Value, role: string): Promise<AclResult<boolean>>;
addRoleParents(role: string, parents: Values): Promise<AclResult>;
removeRoleParents(role: string, parents?: Values): Promise<AclResult>;
removeRole(role: string): Promise<AclResult>;
removeResource(resource: string): Promise<AclResult>;
removeAllow(role: string, resources: Values, permissions?: Values): Promise<AclResult>;
allowedPermissions(userId: Value, resources: Values): Promise<AclResult<Record<string, string[]>>>;
isAllowed(userId: Value, resource: string, permissions: Values): Promise<AclResult<boolean>>;
areAnyRolesAllowed(roles: Values, resource: string, permissions: Values): Promise<AclResult<boolean>>;
whatResources(roles: Values, permissions?: Values): Promise<AclResult<Record<string, string[]> | string[]>>;
}

452
packages/acl/dist-in/Acl.js Normal file

File diff suppressed because one or more lines are too long

1
packages/acl/dist-in/acl.test.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export {};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,10 @@
import { MemoryBackend } from './MemoryBackend.js';
import type { IFileStore } from '../interfaces.js';
export declare class FileBackend extends MemoryBackend implements IFileStore {
#private;
constructor(filePath: string);
/** Load stored ACL data from disk into memory. */
read(path?: string): void;
/** Persist current ACL data to disk. */
write(path?: string): void;
}

View File

@ -0,0 +1,44 @@
/**
* @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 } from 'node:fs';
import { dirname } from 'node:path';
import { MemoryBackend } from './MemoryBackend.js';
export class FileBackend extends MemoryBackend {
#path;
constructor(filePath) {
super();
this.#path = filePath;
}
/** Load stored ACL data from disk into memory. */
read(path) {
const target = path ?? this.#path;
try {
const raw = readFileSync(target, 'utf8');
this.buckets = JSON.parse(raw);
}
catch (err) {
const e = err;
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) {
const target = path ?? this.#path;
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, JSON.stringify(this.buckets, null, 2), { mode: 0o600 });
}
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiRmlsZUJhY2tlbmQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi9zcmMvZGF0YS9GaWxlQmFja2VuZC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7R0FLRztBQUNILE9BQU8sRUFBRSxTQUFTLEVBQUUsWUFBWSxFQUFFLGFBQWEsRUFBYyxNQUFNLFNBQVMsQ0FBQztBQUM3RSxPQUFPLEVBQUUsT0FBTyxFQUFFLE1BQU0sV0FBVyxDQUFDO0FBQ3BDLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSxvQkFBb0IsQ0FBQztBQUduRCxNQUFNLE9BQU8sV0FBWSxTQUFRLGFBQWE7SUFDakMsS0FBSyxDQUFTO0lBRXZCLFlBQVksUUFBZ0I7UUFDeEIsS0FBSyxFQUFFLENBQUM7UUFDUixJQUFJLENBQUMsS0FBSyxHQUFHLFFBQVEsQ0FBQztJQUMxQixDQUFDO0lBRUQsa0RBQWtEO0lBQ2xELElBQUksQ0FBQyxJQUFhO1FBQ2QsTUFBTSxNQUFNLEdBQUcsSUFBSSxJQUFJLElBQUksQ0FBQyxLQUFLLENBQUM7UUFDbEMsSUFBSSxDQUFDO1lBQ0QsTUFBTSxHQUFHLEdBQUcsWUFBWSxDQUFDLE1BQU0sRUFBRSxNQUFNLENBQUMsQ0FBQztZQUN6QyxJQUFJLENBQUMsT0FBTyxHQUFHLElBQUksQ0FBQyxLQUFLLENBQUMsR0FBRyxDQUFDLENBQUM7UUFDbkMsQ0FBQztRQUFDLE9BQU8sR0FBWSxFQUFFLENBQUM7WUFDcEIsTUFBTSxDQUFDLEdBQUcsR0FBNEIsQ0FBQztZQUN2QyxJQUFJLENBQUMsQ0FBQyxJQUFJLEtBQUssUUFBUSxFQUFFLENBQUM7Z0JBQ3RCLFNBQVMsQ0FBQyxPQUFPLENBQUMsTUFBTSxDQUFDLEVBQUUsRUFBRSxTQUFTLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQztnQkFDaEQsT0FBTztZQUNYLENBQUM7WUFDRCxJQUFJLEdBQUcsWUFBWSxXQUFXLEVBQUUsQ0FBQztnQkFDN0IsdUJBQXVCO2dCQUN2QixhQUFhLENBQUMsTUFBTSxFQUFFLEVBQUUsRUFBRSxFQUFFLElBQUksRUFBRSxLQUFLLEVBQUUsQ0FBQyxDQUFDO2dCQUMzQyxPQUFPO1lBQ1gsQ0FBQztZQUNELE1BQU0sR0FBRyxDQUFDO1FBQ2QsQ0FBQztJQUNMLENBQUM7SUFFRCx3Q0FBd0M7SUFDeEMsS0FBSyxDQUFDLElBQWE7UUFDZixNQUFNLE1BQU0sR0FBRyxJQUFJLElBQUksSUFBSSxDQUFDLEtBQUssQ0FBQztRQUNsQyxTQUFTLENBQUMsT0FBTyxDQUFDLE1BQU0sQ0FBQyxFQUFFLEVBQUUsU0FBUyxFQUFFLElBQUksRUFBRSxDQUFDLENBQUM7UUFDaEQsYUFBYSxDQUFDLE1BQU0sRUFBRSxJQUFJLENBQUMsU0FBUyxDQUFDLElBQUksQ0FBQyxPQUFPLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQyxFQUFFLEVBQUUsSUFBSSxFQUFFLEtBQUssRUFBRSxDQUFDLENBQUM7SUFDbEYsQ0FBQztDQUNKIn0=

View File

@ -0,0 +1,25 @@
/**
* @polymech/acl In-memory backend
*
* Transaction = array of deferred mutations, executed on `end()`.
* All reads are synchronous (wrapped as async for the interface).
*/
import type { IBackend, Value, Values } from '../interfaces.js';
type Transaction = (() => void)[];
type BucketStore = Record<string, Record<string, string[]>>;
export declare class MemoryBackend implements IBackend<Transaction> {
#private;
/** Expose raw data (used by FileBackend for serialisation). */
get buckets(): BucketStore;
set buckets(data: BucketStore);
begin(): Transaction;
end(transaction: Transaction): Promise<void>;
clean(): Promise<void>;
get(bucket: string, key: Value): Promise<string[]>;
union(bucket: string, keys: Value[]): Promise<string[]>;
unions(buckets: string[], keys: Value[]): Promise<Record<string, string[]>>;
add(transaction: Transaction, bucket: string, key: Value, values: Values): void;
del(transaction: Transaction, bucket: string, keys: Values): void;
remove(transaction: Transaction, bucket: string, key: Value, values: Values): void;
}
export {};

File diff suppressed because one or more lines are too long

14
packages/acl/dist-in/index.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
/**
* @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, AclErrorCode, AclOk, AclErr, AclResult, BucketNames, Value, Values, } from './interfaces.js';
export { ok, okVoid, err } from './interfaces.js';
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, VfsGroup } from './vfs/vfs-acl.js';
export { DefaultSanitizers } from './vfs/sanitizers.js';
export { assertNonEmpty, cleanPath, pathSegments, normalisePath, cleanPermission, cleanPermissions, isUuid, cleanUuid, cleanId, cleanGroupName, sanitizeSubpath, sanitizeWritePath, sanitizeFilename, } from './vfs/sanitizers.js';

View File

@ -0,0 +1,14 @@
/**
* @polymech/acl Public API
*/
export { Acl } from './Acl.js';
export { MemoryBackend } from './data/MemoryBackend.js';
export { FileBackend } from './data/FileBackend.js';
export { ok, okVoid, err } 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 { DefaultSanitizers } from './vfs/sanitizers.js';
export { assertNonEmpty, cleanPath, pathSegments, normalisePath, cleanPermission, cleanPermissions, isUuid, cleanUuid, cleanId, cleanGroupName, sanitizeSubpath, sanitizeWritePath, sanitizeFilename, } from './vfs/sanitizers.js';
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi9zcmMvaW5kZXgudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUE7O0dBRUc7QUFDSCxPQUFPLEVBQUUsR0FBRyxFQUFFLE1BQU0sVUFBVSxDQUFDO0FBQy9CLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSx5QkFBeUIsQ0FBQztBQUN4RCxPQUFPLEVBQUUsV0FBVyxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFnQnBELE9BQU8sRUFBRSxFQUFFLEVBQUUsTUFBTSxFQUFFLEdBQUcsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBRWxELE1BQU07QUFDTixPQUFPLEVBQUUsWUFBWSxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFDckQsT0FBTyxFQUFFLGtCQUFrQixFQUFFLE1BQU0sNkJBQTZCLENBQUM7QUFDakUsT0FBTyxFQUFFLGVBQWUsRUFBRSxXQUFXLEVBQUUsYUFBYSxFQUFFLE1BQU0sa0JBQWtCLENBQUM7QUFFL0UsT0FBTyxFQUFFLGlCQUFpQixFQUFFLE1BQU0scUJBQXFCLENBQUM7QUFDeEQsT0FBTyxFQUNILGNBQWMsRUFDZCxTQUFTLEVBQUUsWUFBWSxFQUFFLGFBQWEsRUFDdEMsZUFBZSxFQUFFLGdCQUFnQixFQUNqQyxNQUFNLEVBQUUsU0FBUyxFQUFFLE9BQU8sRUFBRSxjQUFjLEVBQzFDLGVBQWUsRUFBRSxpQkFBaUIsRUFBRSxnQkFBZ0IsR0FDdkQsTUFBTSxxQkFBcUIsQ0FBQyJ9

80
packages/acl/dist-in/interfaces.d.ts vendored Normal file
View File

@ -0,0 +1,80 @@
/**
* @polymech/acl Type definitions
*
* Pure ESM, zero external dependencies.
* All methods are async (native Promise).
*/
export type Value = string | number;
export type Values = Value | Value[];
export type AclErrorCode = 'OK' | 'INVALID_INPUT' | 'NOT_FOUND' | 'BACKEND_ERROR';
export interface AclOk<T = void> {
readonly ok: true;
readonly code: 'OK';
readonly data: T;
}
export interface AclErr {
readonly ok: false;
readonly code: Exclude<AclErrorCode, 'OK'>;
readonly message: string;
}
export type AclResult<T = void> = AclOk<T> | AclErr;
export declare const ok: <T>(data: T) => AclOk<T>;
export declare const okVoid: AclOk<void>;
export declare const err: (code: AclErr["code"], message: string) => AclErr;
export interface BucketNames {
readonly meta: string;
readonly parents: string;
readonly permissions: string;
readonly resources: string;
readonly roles: string;
readonly users: string;
}
export interface AclOptions {
buckets?: Partial<BucketNames>;
}
/**
* Transaction-based storage backend.
*
* `T` is the transaction type (e.g. `(() => void)[]` for in-memory).
*/
export interface IBackend<T = unknown> {
begin(): T | Promise<T>;
end(transaction: T): Promise<void>;
clean(): Promise<void>;
get(bucket: string, key: Value): Promise<string[]>;
union(bucket: string, keys: Value[]): Promise<string[]>;
unions(buckets: string[], keys: Value[]): Promise<Record<string, string[]>>;
add(transaction: T, bucket: string, key: Value, values: Values): void | Promise<void>;
del(transaction: T, bucket: string, keys: Values): void | Promise<void>;
remove(transaction: T, bucket: string, key: Value, values: Values): void | Promise<void>;
}
export interface IAcl {
allow(roles: Values, resources: Values, permissions: Values): Promise<AclResult>;
allow(grants: AclGrant[]): Promise<AclResult>;
addUserRoles(userId: Value, roles: Values): Promise<AclResult>;
removeUserRoles(userId: Value, roles: Values): Promise<AclResult>;
userRoles(userId: Value): Promise<AclResult<string[]>>;
roleUsers(role: Value): Promise<AclResult<string[]>>;
hasRole(userId: Value, role: string): Promise<AclResult<boolean>>;
addRoleParents(role: string, parents: Values): Promise<AclResult>;
removeRoleParents(role: string, parents?: Values): Promise<AclResult>;
removeRole(role: string): Promise<AclResult>;
removeResource(resource: string): Promise<AclResult>;
removeAllow(role: string, resources: Values, permissions?: Values): Promise<AclResult>;
allowedPermissions(userId: Value, resources: Values): Promise<AclResult<Record<string, string[]>>>;
isAllowed(userId: Value, resource: string, permissions: Values): Promise<AclResult<boolean>>;
areAnyRolesAllowed(roles: Values, resource: string, permissions: Values): Promise<AclResult<boolean>>;
whatResources(roles: Values, permissions?: Values): Promise<AclResult<Record<string, string[]> | string[]>>;
}
export interface AclGrant {
roles: Values;
allows: AclAllow[];
}
export interface AclAllow {
resources: Values;
permissions: Values;
}
export interface IFileStore {
read(path?: string): void | Promise<void>;
write(path?: string): void | Promise<void>;
}

View File

@ -0,0 +1,11 @@
/**
* @polymech/acl Type definitions
*
* Pure ESM, zero external dependencies.
* All methods are async (native Promise).
*/
// Result constructors
export const ok = (data) => ({ ok: true, code: 'OK', data });
export const okVoid = Object.freeze({ ok: true, code: 'OK', data: undefined });
export const err = (code, message) => ({ ok: false, code, message });
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW50ZXJmYWNlcy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uL3NyYy9pbnRlcmZhY2VzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7OztHQUtHO0FBaUNILHNCQUFzQjtBQUN0QixNQUFNLENBQUMsTUFBTSxFQUFFLEdBQUcsQ0FBSSxJQUFPLEVBQVksRUFBRSxDQUFDLENBQUMsRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxJQUFJLEVBQUUsSUFBSSxFQUFFLENBQUMsQ0FBQztBQUM3RSxNQUFNLENBQUMsTUFBTSxNQUFNLEdBQWdCLE1BQU0sQ0FBQyxNQUFNLENBQUMsRUFBRSxFQUFFLEVBQUUsSUFBSSxFQUFFLElBQUksRUFBRSxJQUFJLEVBQUUsSUFBSSxFQUFFLFNBQVMsRUFBRSxDQUFnQixDQUFDO0FBQzNHLE1BQU0sQ0FBQyxNQUFNLEdBQUcsR0FBRyxDQUFDLElBQW9CLEVBQUUsT0FBZSxFQUFVLEVBQUUsQ0FBQyxDQUFDLEVBQUUsRUFBRSxFQUFFLEtBQUssRUFBRSxJQUFJLEVBQUUsT0FBTyxFQUFFLENBQUMsQ0FBQyJ9

View File

@ -0,0 +1,46 @@
/**
* 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 { type IDefaultParameters } from './fs/Local.js';
export declare class AclVfsClient {
#private;
/**
* @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);
stat(path: string): Promise<INode>;
readdir(path: string): Promise<INode[]>;
readfile(path: string, options?: Record<string, unknown>): Promise<{
stream: ReadStream;
meta: unknown;
}>;
exists(path: string): Promise<boolean>;
writefile(path: string, content: string | Buffer, options?: Record<string, unknown>): Promise<void>;
mkfile(path: string): Promise<void>;
mkdir(path: string): Promise<void>;
rmfile(path: string): Promise<void>;
rmdir(path: string): Promise<void>;
rename(from: string, to: string): Promise<void>;
copy(from: string, to: string): Promise<void>;
}

View File

@ -0,0 +1,88 @@
import { LocalVFS } from './fs/Local.js';
import { resourceChain } from './vfs-acl.js';
import { cleanUuid, sanitizeSubpath } from './sanitizers.js';
export class AclVfsClient {
#acl;
#local;
#ownerId;
#callerId;
/**
* @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, ownerId, callerId, fsOpts) {
this.#acl = acl;
this.#local = new LocalVFS(fsOpts);
this.#ownerId = cleanUuid(ownerId);
this.#callerId = cleanUuid(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, path) {
const safePath = sanitizeSubpath(path);
const chain = resourceChain(this.#ownerId, safePath);
for (const resource of chain) {
const result = await this.#acl.isAllowed(this.#callerId, resource, permission);
if (result.ok && result.data)
return;
}
const err = new Error(`EACCES: user '${this.#callerId}' lacks '${permission}' on path '${path}'`);
err.code = 'EACCES';
throw err;
}
// ── Read operations ─────────────────────────────────────────────
async stat(path) {
await this.#guard('read', path);
return this.#local.stat(path);
}
async readdir(path) {
await this.#guard('list', path);
return this.#local.readdir(path);
}
async readfile(path, options) {
await this.#guard('read', path);
return this.#local.readfile(path, options);
}
async exists(path) {
await this.#guard('read', path);
return this.#local.exists(path);
}
// ── Write operations ────────────────────────────────────────────
async writefile(path, content, options) {
await this.#guard('write', path);
return this.#local.writefile(path, content, options);
}
async mkfile(path) {
await this.#guard('write', path);
return this.#local.mkfile(path);
}
async mkdir(path) {
await this.#guard('mkdir', path);
return this.#local.mkdir(path, { recursive: true });
}
// ── Delete operations ───────────────────────────────────────────
async rmfile(path) {
await this.#guard('delete', path);
return this.#local.rmfile(path);
}
async rmdir(path) {
await this.#guard('delete', path);
return this.#local.rmdir(path);
}
// ── Move / Copy ─────────────────────────────────────────────────
async rename(from, to) {
await this.#guard('rename', from);
return this.#local.rename(from, to);
}
async copy(from, to) {
await this.#guard('copy', from);
return this.#local.copy(from, to);
}
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiQWNsVmZzQ2xpZW50LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL3Zmcy9BY2xWZnNDbGllbnQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBcUJBLE9BQU8sRUFBRSxRQUFRLEVBQTJCLE1BQU0sZUFBZSxDQUFDO0FBQ2xFLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSxjQUFjLENBQUM7QUFDN0MsT0FBTyxFQUFFLFNBQVMsRUFBRSxlQUFlLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQUU3RCxNQUFNLE9BQU8sWUFBWTtJQUNaLElBQUksQ0FBTTtJQUNWLE1BQU0sQ0FBVztJQUNqQixRQUFRLENBQVM7SUFDakIsU0FBUyxDQUFTO0lBRTNCOzs7OztPQUtHO0lBQ0gsWUFBWSxHQUFRLEVBQUUsT0FBZSxFQUFFLFFBQWdCLEVBQUUsTUFBMEI7UUFDL0UsSUFBSSxDQUFDLElBQUksR0FBRyxHQUFHLENBQUM7UUFDaEIsSUFBSSxDQUFDLE1BQU0sR0FBRyxJQUFJLFFBQVEsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUNuQyxJQUFJLENBQUMsUUFBUSxHQUFHLFNBQVMsQ0FBQyxPQUFPLENBQUMsQ0FBQztRQUNuQyxJQUFJLENBQUMsU0FBUyxHQUFHLFNBQVMsQ0FBQyxRQUFRLENBQUMsQ0FBQztJQUN6QyxDQUFDO0lBRUQsbUVBQW1FO0lBRW5FOzs7O09BSUc7SUFDSCxLQUFLLENBQUMsTUFBTSxDQUFDLFVBQWtCLEVBQUUsSUFBWTtRQUN6QyxNQUFNLFFBQVEsR0FBRyxlQUFlLENBQUMsSUFBSSxDQUFDLENBQUM7UUFDdkMsTUFBTSxLQUFLLEdBQUcsYUFBYSxDQUFDLElBQUksQ0FBQyxRQUFRLEVBQUUsUUFBUSxDQUFDLENBQUM7UUFFckQsS0FBSyxNQUFNLFFBQVEsSUFBSSxLQUFLLEVBQUUsQ0FBQztZQUMzQixNQUFNLE1BQU0sR0FBRyxNQUFNLElBQUksQ0FBQyxJQUFJLENBQUMsU0FBUyxDQUFDLElBQUksQ0FBQyxTQUFTLEVB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

View File

@ -0,0 +1,46 @@
/**
* 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';
/**
* 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 declare class DecoratedVfsClient {
readonly acl: Acl;
readonly local: LocalVFS;
readonly ownerId: string;
readonly callerId: string;
constructor(acl: Acl, ownerId: string, callerId: string, fsOpts: IDefaultParameters);
stat(path: string): Promise<INode>;
readdir(path: string): Promise<INode[]>;
readfile(path: string, options?: Record<string, unknown>): Promise<{
stream: ReadStream;
meta: unknown;
}>;
exists(path: string): Promise<boolean>;
writefile(path: string, content: string | Buffer, options?: Record<string, unknown>): Promise<void>;
mkfile(path: string): Promise<void>;
mkdir(path: string): Promise<void>;
rmfile(path: string): Promise<void>;
rmdir(path: string): Promise<void>;
rename(from: string, to: string): Promise<void>;
copy(from: string, to: string): Promise<void>;
}

View File

@ -0,0 +1,100 @@
import { LocalVFS } from './fs/Local.js';
import { resourceChain } from './vfs-acl.js';
import { cleanUuid, sanitizeSubpath } from './sanitizers.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) {
return function (target, context) {
const methodName = String(context.name);
return async function (...args) {
const path = sanitizeSubpath(args[0]);
const chain = resourceChain(this.ownerId, path);
for (const resource of chain) {
const result = await this.acl.isAllowed(this.callerId, resource, permission);
if (result.ok && result.data) {
return target.call(this, ...args);
}
}
const err = new Error(`EACCES: user '${this.callerId}' lacks '${permission}' on path '${path}' [${methodName}]`);
err.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 {
acl;
local;
ownerId;
callerId;
constructor(acl, ownerId, callerId, fsOpts) {
this.acl = acl;
this.local = new LocalVFS(fsOpts);
this.ownerId = cleanUuid(ownerId);
this.callerId = cleanUuid(callerId);
}
// ── Read ────────────────────────────────────────────────────────
@aclGuard('read')
async stat(path) {
return this.local.stat(path);
}
@aclGuard('list')
async readdir(path) {
return this.local.readdir(path);
}
@aclGuard('read')
async readfile(path, options) {
return this.local.readfile(path, options);
}
@aclGuard('read')
async exists(path) {
return this.local.exists(path);
}
// ── Write ───────────────────────────────────────────────────────
@aclGuard('write')
async writefile(path, content, options) {
return this.local.writefile(path, content, options);
}
@aclGuard('write')
async mkfile(path) {
return this.local.mkfile(path);
}
@aclGuard('mkdir')
async mkdir(path) {
return this.local.mkdir(path, { recursive: true });
}
// ── Delete ──────────────────────────────────────────────────────
@aclGuard('delete')
async rmfile(path) {
return this.local.rmfile(path);
}
@aclGuard('delete')
async rmdir(path) {
return this.local.rmdir(path);
}
// ── Move / Copy ─────────────────────────────────────────────────
@aclGuard('rename')
async rename(from, to) {
return this.local.rename(from, to);
}
@aclGuard('copy')
async copy(from, to) {
return this.local.copy(from, to);
}
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiRGVjb3JhdGVkVmZzQ2xpZW50LmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vc3JjL3Zmcy9EZWNvcmF0ZWRWZnNDbGllbnQudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBa0JBLE9BQU8sRUFBRSxRQUFRLEVBQTJCLE1BQU0sZUFBZSxDQUFDO0FBQ2xFLE9BQU8sRUFBRSxhQUFhLEVBQUUsTUFBTSxjQUFjLENBQUM7QUFDN0MsT0FBTyxFQUFFLFNBQVMsRUFBRSxlQUFlLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQUU3RCw4RUFBOEU7QUFDOUUsb0JBQW9CO0FBQ3BCLDhFQUE4RTtBQUU5RTs7Ozs7R0FLRztBQUNILFNBQVMsUUFBUSxDQUFDLFVBQWtCO0lBQ2hDLE9BQU8sVUFDSCxNQUEyQyxFQUMzQyxPQUE0RTtRQUU1RSxNQUFNLFVBQVUsR0FBRyxNQUFNLENBQUMsT0FBTyxDQUFDLElBQUksQ0FBQyxDQUFDO1FBRXhDLE9BQU8sS0FBSyxXQUFvQixHQUFHLElBQU87WUFDdEMsTUFBTSxJQUFJLEdBQUcsZUFBZSxDQUFDLElBQUksQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDO1lBQ3RDLE1BQU0sS0FBSyxHQUFHLGFBQWEsQ0FBQyxJQUFJLENBQUMsT0FBTyxFQUFFLElBQUksQ0FBQyxDQUFDO1lBRWhELEtBQUssTUFBTSxRQUFRLElBQUksS0FBSyxFQUFFLENBQUM7Z0JBQzNCLE1BQU0sTUFBTSxHQUFHLE1BQU0sSUFBSSxDQUFDLEdBQUcsQ0FBQyxTQUFTLENBQUMsSUFBSSxDQUFDLFFBQVEsRUFBRSxRQUFRLEVBQUUsVUFBVSxDQUFDLENBQUM7Z0JBQzdFLElBQUksTUFBTSxDQUFDLEVBQUUsSUFBSSxNQUFNLENBQUMsSUFBSSxFQUFFLENBQUM7b0JBQzNCLE9BQU8sTUFBTSxDQUFDLElBQUksQ0FBQyxJQUFJLEVBQUUsR0FBRyxJQUFJLENBQUMsQ0FBQztnQkFDdEMsQ0FBQztZQUNMLENBQUM7WUFFRCxNQUFNLEdBQUcsR0FB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

49
packages/acl/dist-in/vfs/fs/Local.d.ts vendored Normal file
View File

@ -0,0 +1,49 @@
import { type ReadStream } from 'fs';
import type { INode } from './VFS.js';
import type { FileResource } from './Resource.js';
export interface IDefaultParameters {
readonly root: string;
nopty?: boolean;
local?: boolean;
metapath?: boolean;
defaultEnv?: any;
umask?: string | number;
checkSymlinks?: boolean;
wsmetapath?: boolean;
testing?: boolean;
}
export declare class LocalVFS {
private fsOptions;
private root;
private base;
private umask;
private ig;
constructor(fsOptions: IDefaultParameters, _resource?: FileResource);
/**
* Check if a relative path is ignored by .gitignore rules.
*/
isIgnored(relativePath: string): boolean;
/**
* 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 resolvePath;
stat(path: string, _options?: any): Promise<INode>;
private createStatEntry;
readfile(path: string, options?: any): Promise<{
stream: ReadStream;
meta: any;
}>;
writefile(path: string, content: string | Buffer, options?: any): Promise<void>;
readdir(path: string, _options?: any): Promise<INode[]>;
mkfile(path: string, options?: any): Promise<void>;
mkdir(path: string, options?: any): Promise<void>;
mkdirP(path: string, options?: any): Promise<void>;
rmfile(path: string, _options?: any): Promise<void>;
rmdir(path: string, _options?: any): Promise<void>;
rename(from: string, to: string, _options?: any): Promise<void>;
copy(from: string, to: string, _options?: any): Promise<void>;
exists(path: string): Promise<boolean>;
private calcEtag;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,55 @@
export interface Hash<T> {
[id: string]: T;
}
export interface List<T> {
[index: number]: T;
length: number;
}
/**
* Interface of the simple literal object with any string keys.
*/
export interface IObjectLiteral {
[key: string]: any;
}
/**
* Represents some Type of the Object.
*/
export type ObjectType<T> = (new () => T) | ((...args: never[]) => unknown);
/**
* Same as Partial<T> but goes deeper and makes Partial<T> all its properties and sub-properties.
*/
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
export interface IDelimitter {
begin: '%';
end: '%';
}
export declare enum EResourceType {
JS_HEADER_INCLUDE,
JS_HEADER_SCRIPT_TAG,
CSS,
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 declare function DefaultDelimitter(): IDelimitter;
export interface IResourceDriven {
configPath?: string | null;
relativeVariables: any;
absoluteVariables: any;
}
export type FileResource = IResource & IFileResource;

View File

@ -0,0 +1,14 @@
export var EResourceType;
(function (EResourceType) {
EResourceType[EResourceType["JS_HEADER_INCLUDE"] = 'JS-HEADER-INCLUDE'] = "JS_HEADER_INCLUDE";
EResourceType[EResourceType["JS_HEADER_SCRIPT_TAG"] = 'JS-HEADER-SCRIPT-TAG'] = "JS_HEADER_SCRIPT_TAG";
EResourceType[EResourceType["CSS"] = 'CSS'] = "CSS";
EResourceType[EResourceType["FILE_PROXY"] = 'FILE_PROXY'] = "FILE_PROXY";
})(EResourceType || (EResourceType = {}));
export function DefaultDelimitter() {
return {
begin: '%',
end: '%'
};
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiUmVzb3VyY2UuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvdmZzL2ZzL1Jlc291cmNlLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQThCQSxNQUFNLENBQU4sSUFBWSxhQUtYO0FBTEQsV0FBWSxhQUFhO0lBQ3hCLG1EQUFvQixtQkFBMEIsdUJBQUEsQ0FBQTtJQUM5QyxzREFBdUIsc0JBQTZCLDBCQUFBLENBQUE7SUFDcEQscUNBQU0sS0FBWSxTQUFBLENBQUE7SUFDbEIsNENBQWEsWUFBbUIsZ0JBQUEsQ0FBQTtBQUNqQyxDQUFDLEVBTFcsYUFBYSxLQUFiLGFBQWEsUUFLeEI7QUFvQkQsTUFBTSxVQUFVLGlCQUFpQjtJQUNoQyxPQUFPO1FBQ04sS0FBSyxFQUFFLEdBQUc7UUFDVixHQUFHLEVBQUUsR0FBRztLQUNSLENBQUM7QUFDSCxDQUFDIn0=

115
packages/acl/dist-in/vfs/fs/VFS.d.ts vendored Normal file
View File

@ -0,0 +1,115 @@
/**
* Node types
*
* @export
* @enum {string}
*/
export declare enum ENodeType {
FILE,
DIR,
SYMLINK,
OTHER,
BLOCK
}
/**
* General features of a VFS
*
* @export
* @enum {number}
*/
export declare 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
}
/**
* Supported file operations
*
* @export
* @enum {number}
*/
export declare 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[];
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;
};
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 declare enum NODE_FIELDS {
SHOW_ISDIR = 1602,
SHOW_OWNER = 1604,
SHOW_MIME = 1608,
SHOW_SIZE = 1616,
SHOW_PERMISSIONS = 1632,
SHOW_TIME = 1633,
SHOW_FOLDER_SIZE = 1634,
SHOW_FOLDER_HIDDEN = 1635,
SHOW_TYPE = 1636,
SHOW_MEDIA_INFO = 1637
}
export declare class MountManager {
private mounts;
constructor(mounts: IMount[]);
findByName(name: string): IMount | undefined;
resolve(vfsPath: string): {
mount: IMount;
path: string;
};
}

View File

@ -0,0 +1,88 @@
/**
* Node types
*
* @export
* @enum {string}
*/
export var ENodeType;
(function (ENodeType) {
ENodeType[ENodeType["FILE"] = 'file'] = "FILE";
ENodeType[ENodeType["DIR"] = 'dir'] = "DIR";
ENodeType[ENodeType["SYMLINK"] = 'symlink'] = "SYMLINK";
ENodeType[ENodeType["OTHER"] = 'other'] = "OTHER";
ENodeType[ENodeType["BLOCK"] = 'block'] = "BLOCK";
})(ENodeType || (ENodeType = {}));
/**
* General features of a VFS
*
* @export
* @enum {number}
*/
export var ECapabilties;
(function (ECapabilties) {
ECapabilties[ECapabilties["VERSIONED"] = 0] = "VERSIONED";
ECapabilties[ECapabilties["CHANGE_MESSAGE"] = 1] = "CHANGE_MESSAGE";
ECapabilties[ECapabilties["META"] = 2] = "META";
ECapabilties[ECapabilties["MIME"] = 3] = "MIME";
ECapabilties[ECapabilties["AUTHORS"] = 4] = "AUTHORS";
ECapabilties[ECapabilties["META_TREE"] = 5] = "META_TREE";
ECapabilties[ECapabilties["ROOT"] = 6] = "ROOT";
ECapabilties[ECapabilties["REMOTE_CONNECTION"] = 7] = "REMOTE_CONNECTION"; // VFS has a remote connection
})(ECapabilties || (ECapabilties = {}));
/**
* Supported file operations
*
* @export
* @enum {number}
*/
export var EOperations;
(function (EOperations) {
EOperations[EOperations["LS"] = 0] = "LS";
EOperations[EOperations["RENAME"] = 1] = "RENAME";
EOperations[EOperations["COPY"] = 2] = "COPY";
EOperations[EOperations["DELETE"] = 3] = "DELETE";
EOperations[EOperations["MOVE"] = 4] = "MOVE";
EOperations[EOperations["GET"] = 5] = "GET";
EOperations[EOperations["SET"] = 6] = "SET";
})(EOperations || (EOperations = {}));
/**
*
* These flags are used to build the result, adaptive.
* @TODO: sync with dgrid#configureColumn
* @export
* @enum {number}
*/
export var NODE_FIELDS;
(function (NODE_FIELDS) {
NODE_FIELDS[NODE_FIELDS["SHOW_ISDIR"] = 1602] = "SHOW_ISDIR";
NODE_FIELDS[NODE_FIELDS["SHOW_OWNER"] = 1604] = "SHOW_OWNER";
NODE_FIELDS[NODE_FIELDS["SHOW_MIME"] = 1608] = "SHOW_MIME";
NODE_FIELDS[NODE_FIELDS["SHOW_SIZE"] = 1616] = "SHOW_SIZE";
NODE_FIELDS[NODE_FIELDS["SHOW_PERMISSIONS"] = 1632] = "SHOW_PERMISSIONS";
NODE_FIELDS[NODE_FIELDS["SHOW_TIME"] = 1633] = "SHOW_TIME";
// @TODO: re-impl. du -ahs/x for windows
NODE_FIELDS[NODE_FIELDS["SHOW_FOLDER_SIZE"] = 1634] = "SHOW_FOLDER_SIZE";
NODE_FIELDS[NODE_FIELDS["SHOW_FOLDER_HIDDEN"] = 1635] = "SHOW_FOLDER_HIDDEN";
NODE_FIELDS[NODE_FIELDS["SHOW_TYPE"] = 1636] = "SHOW_TYPE";
NODE_FIELDS[NODE_FIELDS["SHOW_MEDIA_INFO"] = 1637] = "SHOW_MEDIA_INFO";
})(NODE_FIELDS || (NODE_FIELDS = {}));
export class MountManager {
mounts;
constructor(mounts) {
this.mounts = mounts;
}
findByName(name) {
return this.mounts.find(m => m.name === name);
}
resolve(vfsPath) {
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 };
}
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiVkZTLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vc3JjL3Zmcy9mcy9WRlMudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQ0E7Ozs7O0dBS0c7QUFDSCxNQUFNLENBQU4sSUFBWSxTQU1YO0FBTkQsV0FBWSxTQUFTO0lBQ3BCLDhCQUFPLE1BQWEsVUFBQSxDQUFBO0lBQ3BCLDZCQUFNLEtBQVksU0FBQSxDQUFBO0lBQ2xCLGlDQUFVLFNBQWdCLGFBQUEsQ0FBQTtJQUMxQiwrQkFBUSxPQUFjLFdBQUEsQ0FBQTtJQUN0QiwrQkFBUSxPQUFjLFdBQUEsQ0FBQTtBQUN2QixDQUFDLEVBTlcsU0FBUyxLQUFULFNBQVMsUUFNcEI7QUFDRDs7Ozs7R0FLRztBQUNILE1BQU0sQ0FBTixJQUFZLFlBU1g7QUFURCxXQUFZLFlBQVk7SUFDdkIseURBQWEsQ0FBQTtJQUNiLG1FQUFrQixDQUFBO0lBQ2xCLCtDQUFRLENBQUE7SUFDUiwrQ0FBUSxDQUFBO0lBQ1IscURBQVcsQ0FBQTtJQUNYLHlEQUFhLENBQUE7SUFDYiwrQ0FBUSxDQUFBO0lBQ1IseUVBQXFCLENBQUEsQ0FBQyw4QkFBOEI7QUFDckQsQ0FBQyxFQVRXLFlBQVksS0FBWixZQUFZLFFBU3ZCO0FBQ0Q7Ozs7O0dBS0c7QUFDSCxNQUFNLENBQU4sSUFBWSxXQVFYO0FBUkQsV0FBWSxXQUFXO0lBQ3RCLHlDQUFNLENBQUE7SUFDTixpREFBVSxDQUFBO0lBQ1YsNkNBQVEsQ0FBQTtJQUNSLGlEQUFVLENBQUE7SUFDViw2Q0FBUSxDQUFBO0lBQ1IsMkNBQU8sQ0FBQTtJQUNQLDJDQUFPLENBQUE7QUFDUixDQUFDLEVBUlcsV0FBVyxLQUFYLFdBQVcsUUFRdEI7QUFvREQ7Ozs7OztHQU1HO0FBQ0gsTUFBTSxDQUFOLElBQVksV0FZWDtBQVpELFdBQVksV0FBVztJQUN0Qiw0REFBaUIsQ0FBQTtJQUNqQiw0REFBaUIsQ0FBQTtJQUNqQiwwREFBZ0IsQ0FBQTtJQUNoQiwwREFBZ0IsQ0FBQTtJQUNoQix3RUFBdUIsQ0FBQTtJQUN2QiwwREFBZ0IsQ0FBQTtJQUNoQix3Q0FBd0M7SUFDeEMsd0VBQXVCLENBQUE7SUFDdkIsNEVBQXlCLENBQUE7SUFDekIsMERBQWdCLENBQUE7SUFDaEIsc0VBQXNCLENBQUE7QUFDdkIsQ0FBQyxFQVpXLFdBQVcsS0FBWCxXQUFXLFFBWXRCO0FBRUQsTUFBTSxPQUFPLFlBQVk7SUFDaEIsTUFBTSxDQUFXO0lBRXpCLFlBQVksTUFBZ0I7UUFDM0IsSUFBSSxDQUFDLE1BQU0sR0FBRyxNQUFNLENBQUM7SUFDdEIsQ0FBQztJQUVELFVBQVUsQ0FBQyxJQUFZO1FBQ3RCLE9BQU8sSUFBSSxDQUFDLE1BQU0sQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSSxLQUFLLElBQUksQ0FBQyxDQUFDO0lBQy9DLENBQUM7SUFFRCxPQUFPLENBQUMsT0FBZTtRQUN0QixNQUFNLEtBQUssR0FBRyxPQUFPLENBQUMsS0FBSyxDQUFDLEdBQUcsQ0FBQyxDQUFDO1FBQ2pDLE1BQU0sU0FBUyxHQUFHLEtBQUssQ0FBQyxDQUFDLENBQUMsSUFBSSxFQUFFLENBQUM7UUFDakMsTUFBTSxJQUFJLEdBQUcsS0FBSyxDQUFDLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLENBQUM7UUFDdEMsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLFVBQVUsQ0FBQyxTQUFTLENBQUMsQ0FBQztRQUN6QyxJQUFJLENBQUMsS0FBSyxFQUFFLENBQUM7WUFDWixNQUFNLElBQUksS0FBSyxDQUFDLG9CQUFvQixTQUFTLEVBQUUsQ0FBQyxDQUFDO1FBQ2xELENBQUM7UUFDRCxPQUFPLEVBQUUsS0FBSyxFQUFFLElBQUksRUFBRSxDQUFDO0lBQ3hCLENBQUM7Q0FDRCJ9

View File

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

View File

@ -0,0 +1,3 @@
export * from './Local.js';
export * from './VFS.js';
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvdmZzL2ZzL2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGNBQWMsWUFBWSxDQUFDO0FBQzNCLGNBQWMsVUFBVSxDQUFDIn0=

View File

@ -0,0 +1,53 @@
/**
* 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
*/
/**
* 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 declare function sanitizeSubpath(input: string): string;
/**
* 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 declare function sanitizeWritePath(subpath: string, opts?: {
isDirectory?: boolean;
}): string;
/**
* 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 declare function sanitizeFilename(input: string, replacement?: string): string;

File diff suppressed because one or more lines are too long

105
packages/acl/dist-in/vfs/sanitizers.d.ts vendored Normal file
View File

@ -0,0 +1,105 @@
/**
* Default input sanitizers for ACL.
*
* Reusable, pure functions no side effects, no I/O.
* Exported as the `DefaultSanitizers` namespace for convenient access.
*
* Used by:
* - Core Acl class (assertNonEmpty)
* - VFS ACL bridge (cleanUuid, cleanGroupName, normalisePath, cleanPermissions)
* - AclVfsClient / DecoratedVfsClient (cleanUuid, sanitizeSubpath)
*/
/**
* Normalise a subpath for VFS use.
*
* - Converts backslashes to forward slashes
* - Strips leading and trailing slashes
* - Collapses consecutive slashes
*
* @example cleanPath('\\docs\\sub/') 'docs/sub'
* @example cleanPath('/') ''
* @example cleanPath('') ''
*/
export declare function cleanPath(raw: string): string;
/**
* Split a cleaned path into individual segments.
*
* @example pathSegments('docs/sub/file.txt') ['docs', 'sub', 'file.txt']
* @example pathSegments('') []
*/
export declare function pathSegments(raw: string): string[];
/**
* Normalise a path into its absolute VFS form (leading slash, no trailing).
*
* @example normalisePath('docs/sub/') '/docs/sub'
* @example normalisePath('') '/'
* @example normalisePath('\\a\\b') '/a/b'
*/
export declare function normalisePath(raw: string): string;
/**
* Validate and normalise a permission name.
*
* - Lowercased
* - Trimmed
* - Rejects empty strings
*
* @throws Error if the permission is empty after trimming.
*/
export declare function cleanPermission(raw: string): string;
/**
* Validate and normalise permission arrays.
*/
export declare function cleanPermissions(raw: string[]): string[];
/**
* Test whether a string is a valid UUID.
*
* @example isUuid('3bb4cfbf-318b-44d3-a9d3-35680e738421') true
* @example isUuid('not-a-uuid') false
*/
export declare function isUuid(value: string): boolean;
/**
* Validate and normalise a UUID string (lowercased, trimmed).
*
* @throws Error if the value is not a valid UUID.
*/
export declare function cleanUuid(raw: string): string;
/**
* Validate a user/owner identifier must be a valid UUID.
*
* @throws Error if the identifier is not a valid UUID.
*/
export declare function cleanId(raw: string): string;
/**
* Validate a group name (non-empty, lowercased, no colons).
*
* @throws Error if the name is empty or contains reserved characters.
*/
export declare function cleanGroupName(raw: string): string;
/**
* Reject empty/whitespace-only identifiers.
* Works with single values or arrays of values (string | number).
*
* @throws Error with a descriptive label if any value is empty.
*
* @example assertNonEmpty('admin', 'Role') // ok
* @example assertNonEmpty('', 'Role') // throws "Role cannot be empty"
* @example assertNonEmpty(['a', ''], 'Role') // throws "Role cannot be empty"
*/
export declare function assertNonEmpty(value: string | number | (string | number)[], label: string): void;
export { sanitizeSubpath, sanitizeWritePath, sanitizeFilename } from './path-sanitizer.js';
import { sanitizeSubpath, sanitizeWritePath, sanitizeFilename } from './path-sanitizer.js';
export declare const DefaultSanitizers: {
readonly assertNonEmpty: typeof assertNonEmpty;
readonly cleanPath: typeof cleanPath;
readonly pathSegments: typeof pathSegments;
readonly normalisePath: typeof normalisePath;
readonly cleanPermission: typeof cleanPermission;
readonly cleanPermissions: typeof cleanPermissions;
readonly isUuid: typeof isUuid;
readonly cleanUuid: typeof cleanUuid;
readonly cleanId: typeof cleanId;
readonly cleanGroupName: typeof cleanGroupName;
readonly sanitizeSubpath: typeof sanitizeSubpath;
readonly sanitizeWritePath: typeof sanitizeWritePath;
readonly sanitizeFilename: typeof sanitizeFilename;
};

View File

@ -0,0 +1,163 @@
/**
* Default input sanitizers for ACL.
*
* Reusable, pure functions no side effects, no I/O.
* Exported as the `DefaultSanitizers` namespace for convenient access.
*
* Used by:
* - Core Acl class (assertNonEmpty)
* - VFS ACL bridge (cleanUuid, cleanGroupName, normalisePath, cleanPermissions)
* - AclVfsClient / DecoratedVfsClient (cleanUuid, sanitizeSubpath)
*/
/**
* Normalise a subpath for VFS use.
*
* - Converts backslashes to forward slashes
* - Strips leading and trailing slashes
* - Collapses consecutive slashes
*
* @example cleanPath('\\docs\\sub/') 'docs/sub'
* @example cleanPath('/') ''
* @example cleanPath('') ''
*/
export function cleanPath(raw) {
return raw
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+/, '')
.replace(/\/+$/, '');
}
/**
* Split a cleaned path into individual segments.
*
* @example pathSegments('docs/sub/file.txt') ['docs', 'sub', 'file.txt']
* @example pathSegments('') []
*/
export function pathSegments(raw) {
const clean = cleanPath(raw);
return clean ? clean.split('/') : [];
}
/**
* Normalise a path into its absolute VFS form (leading slash, no trailing).
*
* @example normalisePath('docs/sub/') '/docs/sub'
* @example normalisePath('') '/'
* @example normalisePath('\\a\\b') '/a/b'
*/
export function normalisePath(raw) {
const clean = cleanPath(raw);
return clean ? `/${clean}` : '/';
}
/**
* Validate and normalise a permission name.
*
* - Lowercased
* - Trimmed
* - Rejects empty strings
*
* @throws Error if the permission is empty after trimming.
*/
export function cleanPermission(raw) {
const p = raw.trim().toLowerCase();
if (!p)
throw new Error('Permission name cannot be empty');
return p;
}
/**
* Validate and normalise permission arrays.
*/
export function cleanPermissions(raw) {
return raw.map(cleanPermission);
}
// ---------------------------------------------------------------------------
// UUID validation
// ---------------------------------------------------------------------------
/** Standard UUID v1v5 pattern (case-insensitive, lowercased on output). */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Test whether a string is a valid UUID.
*
* @example isUuid('3bb4cfbf-318b-44d3-a9d3-35680e738421') true
* @example isUuid('not-a-uuid') false
*/
export function isUuid(value) {
return UUID_RE.test(value.trim());
}
/**
* Validate and normalise a UUID string (lowercased, trimmed).
*
* @throws Error if the value is not a valid UUID.
*/
export function cleanUuid(raw) {
const id = raw.trim().toLowerCase();
if (!UUID_RE.test(id)) {
throw new Error(`Invalid UUID: '${raw}'`);
}
return id;
}
/**
* Validate a user/owner identifier must be a valid UUID.
*
* @throws Error if the identifier is not a valid UUID.
*/
export function cleanId(raw) {
return cleanUuid(raw);
}
/**
* Validate a group name (non-empty, lowercased, no colons).
*
* @throws Error if the name is empty or contains reserved characters.
*/
export function cleanGroupName(raw) {
const name = raw.trim().toLowerCase();
if (!name)
throw new Error('Group name cannot be empty');
if (name.includes(':'))
throw new Error(`Group name cannot contain ':': ${name}`);
return name;
}
// ---------------------------------------------------------------------------
// Generic assertions (used by core Acl)
// ---------------------------------------------------------------------------
/**
* Reject empty/whitespace-only identifiers.
* Works with single values or arrays of values (string | number).
*
* @throws Error with a descriptive label if any value is empty.
*
* @example assertNonEmpty('admin', 'Role') // ok
* @example assertNonEmpty('', 'Role') // throws "Role cannot be empty"
* @example assertNonEmpty(['a', ''], 'Role') // throws "Role cannot be empty"
*/
export function assertNonEmpty(value, label) {
const arr = Array.isArray(value) ? value : [value];
for (const v of arr) {
const s = String(v).trim();
if (!s)
throw new Error(`${label} cannot be empty`);
}
}
// ---------------------------------------------------------------------------
// Re-export path-sanitizer functions for unified access
// ---------------------------------------------------------------------------
export { sanitizeSubpath, sanitizeWritePath, sanitizeFilename } from './path-sanitizer.js';
// ---------------------------------------------------------------------------
// Namespace re-export for convenience
// ---------------------------------------------------------------------------
import { sanitizeSubpath, sanitizeWritePath, sanitizeFilename } from './path-sanitizer.js';
export const DefaultSanitizers = {
assertNonEmpty,
cleanPath,
pathSegments,
normalisePath,
cleanPermission,
cleanPermissions,
isUuid,
cleanUuid,
cleanId,
cleanGroupName,
sanitizeSubpath,
sanitizeWritePath,
sanitizeFilename,
};
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2FuaXRpemVycy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy92ZnMvc2FuaXRpemVycy50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7Ozs7OztHQVVHO0FBRUg7Ozs7Ozs7Ozs7R0FVRztBQUNILE1BQU0sVUFBVSxTQUFTLENBQUMsR0FBVztJQUNqQyxPQUFPLEdBQUc7U0FDTCxPQUFPLENBQUMsS0FBSyxFQUFFLEdBQUcsQ0FBQztTQUNuQixPQUFPLENBQUMsTUFBTSxFQUFFLEdBQUcsQ0FBQztTQUNwQixPQUFPLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQztTQUNuQixPQUFPLENBQUMsTUFBTSxFQUFFLEVBQUUsQ0FBQyxDQUFDO0FBQzdCLENBQUM7QUFFRDs7Ozs7R0FLRztBQUNILE1BQU0sVUFBVSxZQUFZLENBQUMsR0FBVztJQUNwQyxNQUFNLEtBQUssR0FBRyxTQUFTLENBQUMsR0FBRyxDQUFDLENBQUM7SUFDN0IsT0FBTyxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUMsR0FBRyxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQztBQUN6QyxDQUFDO0FBRUQ7Ozs7OztHQU1HO0FBQ0gsTUFBTSxVQUFVLGFBQWEsQ0FBQyxHQUFXO0lBQ3JDLE1BQU0sS0FBSyxHQUFHLFNBQVMsQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUM3QixPQUFPLEtBQUssQ0FBQyxDQUFDLENBQUMsSUFBSSxLQUFLLEVBQUUsQ0FBQyxDQUFDLENBQUMsR0FBRyxDQUFDO0FBQ3JDLENBQUM7QUFFRDs7Ozs7Ozs7R0FRRztBQUNILE1BQU0sVUFBVSxlQUFlLENBQUMsR0FBVztJQUN2QyxNQUFNLENBQUMsR0FBRyxHQUFHLENBQUMsSUFBSSxFQUFFLENBQUMsV0FBVyxFQUFFLENBQUM7SUFDbkMsSUFBSSxDQUFDLENBQUM7UUFBRSxNQUFNLElBQUksS0FBSyxDQUFDLGlDQUFpQyxDQUFDLENBQUM7SUFDM0QsT0FB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

43
packages/acl/dist-in/vfs/vfs-acl.d.ts vendored Normal file
View File

@ -0,0 +1,43 @@
import type { Acl } from '../Acl.js';
export interface VfsGroup {
name: string;
members: string[];
}
export interface VfsAclEntry {
/** Direct user grant */
userId?: string;
/** Group grant */
group?: string;
/** Scoped path — defaults to "/" (entire folder). */
path?: string;
permissions: string[];
}
export interface VfsSettings {
owner: string;
groups?: VfsGroup[];
acl: VfsAclEntry[];
}
/**
* Canonical resource name for a path inside a user's VFS folder.
*
* vfsResource('aaa', '/') 'vfs:aaa:/'
* vfsResource('aaa', '/docs') 'vfs:aaa:/docs'
*/
export declare function vfsResource(ownerId: string, resourcePath?: string): string;
/**
* 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 declare function resourceChain(ownerId: string, subpath: string): string[];
/**
* 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: `/`).
* - Group entries resolve members from the `groups[]` array.
*/
export declare function loadVfsSettings(acl: Acl, userDir: string): Promise<VfsSettings | null>;

View File

@ -0,0 +1,108 @@
/**
* 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.
*
* Supports:
* - Direct user grants: { userId, path, permissions }
* - Group grants: { group, path, permissions }
*
* Resource naming: `vfs:<ownerId>:<path>` e.g. `vfs:3bb4cfbf-...:/docs`
*
* Permissions: read | write | list | mkdir | delete | rename | copy
*/
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { pathSegments, normalisePath, cleanGroupName, cleanId, cleanPermissions } from './sanitizers.js';
// ---------------------------------------------------------------------------
// 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, resourcePath = '/') {
return `vfs:${ownerId}:${normalisePath(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, subpath) {
const segments = pathSegments(subpath);
const chain = [];
// 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: `/`).
* - Group entries resolve members from the `groups[]` array.
*/
export async function loadVfsSettings(acl, userDir) {
const settingsPath = join(userDir, 'vfs-settings.json');
if (!existsSync(settingsPath))
return null;
const raw = readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(raw);
// Validate owner
const safeOwner = cleanId(settings.owner);
// Helper: unwrap result or throw
const unwrap = (result) => {
if (!result.ok)
throw new Error(result.message);
};
// Owner role — full access on entire tree
const ownerRole = `owner:${safeOwner}`;
unwrap(await acl.allow(ownerRole, vfsResource(safeOwner, '/'), '*'));
unwrap(await acl.addUserRoles(safeOwner, ownerRole));
// Index groups (validate member IDs)
const groupMembers = new Map();
for (const group of settings.groups ?? []) {
const safeName = cleanGroupName(group.name);
const safeMembers = group.members.map(m => cleanId(m));
groupMembers.set(safeName, safeMembers);
}
// Process ACL entries
for (const entry of settings.acl) {
const resourcePath = normalisePath(entry.path ?? '/');
const resource = vfsResource(safeOwner, resourcePath);
const safePerms = cleanPermissions(entry.permissions);
if (entry.group) {
// Group grant
const safeGroup = cleanGroupName(entry.group);
const groupRole = `group:${safeOwner}:${safeGroup}`;
unwrap(await acl.allow(groupRole, resource, safePerms));
const members = groupMembers.get(safeGroup) ?? [];
for (const memberId of members) {
unwrap(await acl.addUserRoles(memberId, groupRole));
}
}
else if (entry.userId) {
// Direct user grant
const safeId = cleanId(entry.userId);
const grantRole = `vfs-grant:${safeOwner}:${safeId}:${resourcePath}`;
unwrap(await acl.allow(grantRole, resource, safePerms));
unwrap(await acl.addUserRoles(safeId, grantRole));
}
}
return settings;
}
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidmZzLWFjbC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3NyYy92ZnMvdmZzLWFjbC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7Ozs7Ozs7OztHQWFHO0FBQ0gsT0FBTyxFQUFFLFlBQVksRUFBRSxVQUFVLEVBQUUsTUFBTSxTQUFTLENBQUM7QUFDbkQsT0FBTyxFQUFFLElBQUksRUFBRSxNQUFNLFdBQVcsQ0FBQztBQUVqQyxPQUFPLEVBQUUsWUFBWSxFQUFFLGFBQWEsRUFBRSxjQUFjLEVBQUUsT0FBTyxFQUFFLGdCQUFnQixFQUFFLE1BQU0saUJBQWlCLENBQUM7QUEyQnpHLDhFQUE4RTtBQUM5RSxVQUFVO0FBQ1YsOEVBQThFO0FBRTlFOzs7OztHQUtHO0FBQ0gsTUFBTSxVQUFVLFdBQVcsQ0FBQyxPQUFlLEVBQUUsWUFBWSxHQUFHLEdBQUc7SUFDM0QsT0FBTyxPQUFPLE9BQU8sSUFBSSxhQUFhLENBQUMsWUFBWSxDQUFDLEVBQUUsQ0FBQztBQUMzRCxDQUFDO0FBRUQ7Ozs7Ozs7R0FPRztBQUNILE1BQU0sVUFBVSxhQUFhLENBQUMsT0FBZSxFQUFFLE9BQWU7SUFDMUQsTUFBTSxRQUFRLEdBQUcsWUFBWSxDQUFDLE9BQU8sQ0FBQyxDQUFDO0lBRXZDLE1BQU0sS0FBSyxHQUFhLEVBQUUsQ0FBQztJQUUzQiw2Q0FBNkM7SUFDN0MsS0FBSyxJQUFJLENBQUMsR0FBRyxRQUFRLENBQUMsTUFBTSxFQUFFLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQyxFQUFFLEVBQUUsQ0FBQztRQUN2QyxLQUFLLENBQUMsSUFBSSxDQUFDLFdBQVcsQ0FBQyxPQUFPLEVBQUUsR0FBRyxHQUFHLFFBQVEsQ0FBQyxLQUFLLENBQUMsQ0FBQyxFQUFFLENBQUMsQ0FBQyxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQyxDQUFDLENBQUM7SUFDM0UsQ0FBQztJQUVELHNCQUFzQjtJQUN0QixLQUFLLENBQUMsSUFBSSxDQUFDLFdBQVcs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

256
packages/acl/docs/groups.md Normal file
View File

@ -0,0 +1,256 @@
# Groups — Design Proposal
## Problem
Today, VFS permissions are granted **per-user**:
```json
{
"acl": [
{ "userId": "aaa-...", "path": "/docs", "permissions": ["read", "list"] },
{ "userId": "bbb-...", "path": "/docs", "permissions": ["read", "list"] },
{ "userId": "ccc-...", "path": "/docs", "permissions": ["read", "list"] }
]
}
```
If 50 users need the same access, you write 50 entries. Changing the permission set means updating all 50. This doesn't scale.
## Proposal: User Groups
Introduce a **group** — a named set of users. Permissions are granted to groups rather than individual user IDs.
### Data Model
```mermaid
erDiagram
GROUP ||--o{ GROUP_MEMBER : contains
GROUP ||--o{ VFS_PERMISSION : receives
USER ||--o{ GROUP_MEMBER : belongs_to
USER ||--o{ VFS_PERMISSION : owns
GROUP {
uuid id PK
uuid owner_id FK
string name
string description
}
GROUP_MEMBER {
uuid group_id FK
uuid user_id FK
}
VFS_PERMISSION {
uuid owner_id FK
uuid group_id FK "nullable — group grant"
uuid grantee_id FK "nullable — direct grant"
string resource_path
string[] permissions
}
```
A permission row targets **either** a `group_id` or a `grantee_id` — never both.
### VFS Settings (JSON)
```json
{
"owner": "3bb4cfbf-...",
"groups": [
{
"name": "team",
"members": ["aaa-...", "bbb-...", "ccc-..."]
},
{
"name": "viewers",
"members": ["ddd-...", "eee-..."]
}
],
"acl": [
{ "group": "team", "path": "/shared", "permissions": ["read", "write", "list", "mkdir", "delete"] },
{ "group": "viewers", "path": "/docs", "permissions": ["read", "list"] },
{ "userId": "fff-...", "path": "/private/partner", "permissions": ["read", "list"] }
]
}
```
Groups and direct user grants coexist. Direct grants take precedence for individual overrides.
---
## ACL Mapping
### How Groups Become Roles
Each group becomes a role in the ACL. All members of the group inherit that role:
```mermaid
sequenceDiagram
participant Loader as VFS ACL Bridge
participant ACL as Acl Instance
Note over Loader: For each group
Loader->>ACL: allow "group:team", resource, permissions
loop Each member of team
Loader->>ACL: addUserRoles member, "group:team"
end
Note over Loader: For each direct grant
Loader->>ACL: allow "vfs-grant:owner:user", resource, permissions
Loader->>ACL: addUserRoles user, "vfs-grant:owner:user"
```
### Role Naming Convention
| Type | Role Name | Example |
|------|-----------|---------|
| Owner | `owner:<ownerId>` | `owner:3bb4-...` |
| Group | `group:<ownerId>:<groupName>` | `group:3bb4-...:team` |
| Direct | `vfs-grant:<ownerId>:<userId>:<path>` | `vfs-grant:3bb4-...:fff-...:/private` |
---
## Implementation
### TypeScript Types
```ts
interface VfsGroup {
name: string;
members: string[];
}
interface VfsAclEntry {
/** Direct user grant */
userId?: string;
/** Group grant */
group?: string;
/** Scoped path — defaults to "/" */
path?: string;
permissions: string[];
}
interface VfsSettings {
owner: string;
groups?: VfsGroup[];
acl: VfsAclEntry[];
}
```
### Loader Changes
```ts
export async function loadVfsSettings(acl: Acl, userDir: string): Promise<VfsSettings | null> {
// ... read settings ...
// 1. Owner — wildcard on /
const ownerRole = `owner:${settings.owner}`;
await acl.allow(ownerRole, vfsResource(settings.owner, '/'), '*');
await acl.addUserRoles(settings.owner, ownerRole);
// 2. Register groups
const groupMembers = new Map<string, string[]>();
for (const group of settings.groups ?? []) {
groupMembers.set(group.name, group.members);
}
// 3. Process ACL entries
for (const entry of settings.acl) {
const resourcePath = entry.path ?? '/';
const resource = vfsResource(settings.owner, resourcePath);
if (entry.group) {
// Group grant
const role = `group:${settings.owner}:${entry.group}`;
await acl.allow(role, resource, entry.permissions);
const members = groupMembers.get(entry.group) ?? [];
for (const memberId of members) {
await acl.addUserRoles(memberId, role);
}
} else if (entry.userId) {
// Direct grant
const role = `vfs-grant:${settings.owner}:${entry.userId}:${resourcePath}`;
await acl.allow(role, resource, entry.permissions);
await acl.addUserRoles(entry.userId, role);
}
}
return settings;
}
```
### AclVfsClient
**No changes needed.** The client only calls `acl.isAllowed(callerId, resource, permission)` — it doesn't care whether the permission was granted via a group or a direct entry. The ACL resolves it transparently through `addUserRoles`.
---
## Postgres Schema
```sql
create table public.vfs_groups (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references public.users(id) on delete cascade,
name text not null,
description text,
unique (owner_id, name)
);
create table public.vfs_group_members (
group_id uuid not null references public.vfs_groups(id) on delete cascade,
user_id uuid not null references public.users(id) on delete cascade,
primary key (group_id, user_id)
);
-- Extend vfs_permissions to support group grants
alter table public.vfs_permissions
add column group_id uuid references public.vfs_groups(id) on delete cascade;
-- Either group_id or grantee_id must be set, never both
alter table public.vfs_permissions
add constraint grant_target_check
check (
(group_id is not null and grantee_id is null)
or (group_id is null and grantee_id is not null)
);
```
---
## Permission Resolution Order
When checking `isAllowed(userId, resource, permission)`:
```mermaid
flowchart TD
A[isAllowed userId resource permission] --> B{Is owner?}
B -- yes --> C[ALLOW - wildcard]
B -- no --> D{Direct grant exists?}
D -- yes, allowed --> C
D -- no --> E{Any group grants?}
E -- yes --> F{User is member of group?}
F -- yes, allowed --> C
F -- no --> G[Walk parent path]
E -- no --> G
G --> H{Reached root?}
H -- no --> D
H -- yes --> I[DENY]
```
This is already handled by the existing `resourceChain` + `addUserRoles` — no new resolution logic needed.
---
## Open Questions
1. **Nested groups?** — A group containing other groups (e.g. `all-staff` inherits members from `team-a` + `team-b`). This maps to `addRoleParents` but adds complexity. Recommend **no** for v1.
2. **Group-scoped deny?** — Explicitly deny a permission for a group. The current ACL doesn't support deny rules (allow-only). Recommend deferring.
3. **Who can manage groups?** — Only the folder owner, or delegates? RLS naturally restricts to `owner_id = auth.uid()`, but a `group_admin` role could be added later.
4. **Max group size?** — For the in-memory backend, large groups (1000+ members) are fine. For the DB loader, a single `SELECT ... JOIN` handles it. No practical limit for v1.

View File

@ -0,0 +1,45 @@
import tseslint from 'typescript-eslint';
/** @type {import('eslint').Linter.Config[]} */
export default tseslint.config(
{ files: ['src/**/*.ts'] },
...tseslint.configs.recommendedTypeChecked,
...tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
},
},
rules: {
// Aligned with monorepo root config
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/consistent-indexed-object-style': 'off',
'@typescript-eslint/array-type': 'off',
'@typescript-eslint/prefer-for-of': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/await-thenable': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/dot-notation': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/prefer-optional-chain': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-base-to-string': 'off',
'@typescript-eslint/no-redundant-type-constituents': 'off',
'@typescript-eslint/only-throw-error': 'off',
'@typescript-eslint/prefer-promise-reject-errors': 'off',
'@typescript-eslint/unbound-method': 'off',
'prefer-const': 'off',
},
},
);

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,8 @@
"build": "tsc",
"dev": "tsc -p . --watch",
"test:core": "vitest run src/acl.test.ts",
"lint": "eslint src --ext .ts"
"test:all": "vitest run tests/ src/acl.test.ts",
"lint": "eslint src"
},
"dependencies": {
"ignore": "^7.0.5",
@ -35,11 +36,11 @@
"devDependencies": {
"@repo/typescript-config": "file:../typescript-config",
"@types/node": "22.10.2",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitest/coverage-v8": "^2.1.8",
"@vitest/ui": "2.1.9",
"eslint": "^9.39.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.56.0",
"vitest": "^2.1.8"
}
}

View File

@ -6,47 +6,59 @@
*/
import type { Logger } from 'pino';
import type {
AclErr,
AclGrant,
AclOptions,
AclResult,
BucketNames,
IBackend,
IAcl,
Value,
Values,
} from './interfaces.js';
import { ok, okVoid, err } from './interfaces.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function toArray<T>(v: T | T[]): T[] {
return Array.isArray(v) ? v : [v];
}
const toArray = <T>(v: T | T[]): T[] =>
Array.isArray(v) ? v : [v];
function allowsBucket(resource: string): string {
return `allows_${resource}`;
}
const allowsBucket = (resource: string): string =>
`allows_${resource}`;
function keyFromAllowsBucket(str: string): string {
return str.replace(/^allows_/, '');
}
const keyFromAllowsBucket = (str: string): string =>
str.replace(/^allows_/, '');
/** Set-based union of two arrays (deduped). */
function union<T>(a: T[], b: T[]): T[] {
return [...new Set([...a, ...b])];
}
const union = <T>(a: T[], b: T[]): T[] =>
[...new Set([...a, ...b])];
/** Items in `a` that are not in `b`. */
function difference<T>(a: T[], b: T[]): T[] {
const difference = <T>(a: T[], b: T[]): T[] => {
const set = new Set(b);
return a.filter((x) => !set.has(x));
}
};
/** Intersection of `a` and `b`. */
function intersect<T>(a: T[], b: T[]): T[] {
const intersect = <T>(a: T[], b: T[]): T[] => {
const set = new Set(b);
return a.filter((x) => set.has(x));
}
};
/**
* Validate that none of the values are empty/whitespace-only.
* Returns an AclErr if validation fails, otherwise undefined.
*/
const validateNonEmpty = (value: Value | Values, label: string): AclErr | undefined => {
const arr = Array.isArray(value) ? value : [value];
for (const v of arr) {
const s = String(v).trim();
if (!s) return err('INVALID_INPUT', `${label} cannot be empty`);
}
return undefined;
};
// ---------------------------------------------------------------------------
// Default bucket names
@ -80,163 +92,249 @@ export class Acl implements IAcl {
// allow
// -------------------------------------------------------------------------
async allow(rolesOrGrants: Values | AclGrant[], resources?: Values, permissions?: Values): Promise<void> {
// Overload: allow(grants[])
if (Array.isArray(rolesOrGrants) && rolesOrGrants.length > 0 && typeof rolesOrGrants[0] === 'object') {
return this.#allowBatch(rolesOrGrants as AclGrant[]);
}
const roles = toArray(rolesOrGrants as Values) as string[];
const res = toArray(resources!);
const perms = toArray(permissions!);
const tx = this.#backend.begin();
this.#backend.add(tx, this.#buckets.meta, 'roles', roles);
for (const resource of res) {
for (const role of roles) {
this.#backend.add(tx, allowsBucket(String(resource)), role, perms);
async allow(rolesOrGrants: Values | AclGrant[], resources?: Values, permissions?: Values): Promise<AclResult> {
try {
// Overload: allow(grants[])
if (Array.isArray(rolesOrGrants) && rolesOrGrants.length > 0 && typeof rolesOrGrants[0] === 'object') {
return this.#allowBatch(rolesOrGrants as AclGrant[]);
}
}
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');
const roles = toArray(rolesOrGrants as Values) as string[];
const res = toArray(resources!);
const perms = toArray(permissions!);
const v1 = validateNonEmpty(roles, 'Role');
if (v1) return v1;
const v2 = validateNonEmpty(res, 'Resource');
if (v2) return v2;
const v3 = validateNonEmpty(perms, 'Permission');
if (v3) return v3;
const tx = await this.#backend.begin();
await this.#backend.add(tx, this.#buckets.meta, 'roles', roles);
for (const resource of res) {
for (const role of roles) {
await this.#backend.add(tx, allowsBucket(String(resource)), role, perms);
}
}
for (const role of roles) {
await this.#backend.add(tx, this.#buckets.resources, role, res);
}
await this.#backend.end(tx);
this.#logger?.debug({ roles, resources: res, permissions: perms }, 'allow');
return okVoid;
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
async #allowBatch(grants: AclGrant[]): Promise<void> {
const flat: { roles: Values; resources: Values; permissions: Values }[] = [];
async #allowBatch(grants: AclGrant[]): Promise<AclResult> {
for (const g of grants) {
for (const a of g.allows) {
flat.push({ roles: g.roles, resources: a.resources, permissions: a.permissions });
const result = await this.allow(g.roles, a.resources, a.permissions);
if (!result.ok) return result;
}
}
for (const item of flat) {
await this.allow(item.roles, item.resources, item.permissions);
}
return okVoid;
}
// -------------------------------------------------------------------------
// User ↔ Role
// -------------------------------------------------------------------------
async addUserRoles(userId: Value, roles: Values): Promise<void> {
const rolesArr = toArray(roles);
const tx = this.#backend.begin();
this.#backend.add(tx, this.#buckets.meta, 'users', userId);
this.#backend.add(tx, this.#buckets.users, userId, roles);
async addUserRoles(userId: Value, roles: Values): Promise<AclResult> {
try {
const v1 = validateNonEmpty(userId, 'User ID');
if (v1) return v1;
const v2 = validateNonEmpty(roles, 'Role');
if (v2) return v2;
for (const role of rolesArr) {
this.#backend.add(tx, this.#buckets.roles, role, userId);
const rolesArr = toArray(roles);
const tx = await this.#backend.begin();
await this.#backend.add(tx, this.#buckets.meta, 'users', userId);
await this.#backend.add(tx, this.#buckets.users, userId, roles);
for (const role of rolesArr) {
await this.#backend.add(tx, this.#buckets.roles, role, userId);
}
await this.#backend.end(tx);
return okVoid;
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
await this.#backend.end(tx);
}
async removeUserRoles(userId: Value, roles: Values): Promise<void> {
const rolesArr = toArray(roles);
const tx = this.#backend.begin();
this.#backend.remove(tx, this.#buckets.users, userId, roles);
async removeUserRoles(userId: Value, roles: Values): Promise<AclResult> {
try {
const v1 = validateNonEmpty(userId, 'User ID');
if (v1) return v1;
const v2 = validateNonEmpty(roles, 'Role');
if (v2) return v2;
for (const role of rolesArr) {
this.#backend.remove(tx, this.#buckets.roles, role, userId);
const rolesArr = toArray(roles);
const tx = await this.#backend.begin();
await this.#backend.remove(tx, this.#buckets.users, userId, roles);
for (const role of rolesArr) {
await this.#backend.remove(tx, this.#buckets.roles, role, userId);
}
await this.#backend.end(tx);
return okVoid;
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
await this.#backend.end(tx);
}
async userRoles(userId: Value): Promise<string[]> {
return this.#backend.get(this.#buckets.users, userId);
async userRoles(userId: Value): Promise<AclResult<string[]>> {
try {
const data = await this.#backend.get(this.#buckets.users, userId);
return ok(data);
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
async roleUsers(role: Value): Promise<string[]> {
return this.#backend.get(this.#buckets.roles, role);
async roleUsers(role: Value): Promise<AclResult<string[]>> {
try {
const data = await this.#backend.get(this.#buckets.roles, role);
return ok(data);
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
async hasRole(userId: Value, role: string): Promise<boolean> {
const roles = await this.userRoles(userId);
return roles.includes(role);
async hasRole(userId: Value, role: string): Promise<AclResult<boolean>> {
try {
const result = await this.userRoles(userId);
if (!result.ok) return result;
return ok(result.data.includes(role));
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
// -------------------------------------------------------------------------
// Role hierarchy
// -------------------------------------------------------------------------
async addRoleParents(role: string, parents: Values): Promise<void> {
const tx = this.#backend.begin();
this.#backend.add(tx, this.#buckets.meta, 'roles', role);
this.#backend.add(tx, this.#buckets.parents, role, parents);
await this.#backend.end(tx);
async addRoleParents(role: string, parents: Values): Promise<AclResult> {
try {
const v1 = validateNonEmpty(role, 'Role');
if (v1) return v1;
const v2 = validateNonEmpty(parents, 'Parent role');
if (v2) return v2;
const tx = await this.#backend.begin();
await this.#backend.add(tx, this.#buckets.meta, 'roles', role);
await this.#backend.add(tx, this.#buckets.parents, role, parents);
await this.#backend.end(tx);
return okVoid;
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
async removeRoleParents(role: string, parents?: Values): Promise<void> {
const tx = this.#backend.begin();
if (parents) {
this.#backend.remove(tx, this.#buckets.parents, role, parents);
} else {
this.#backend.del(tx, this.#buckets.parents, role);
async removeRoleParents(role: string, parents?: Values): Promise<AclResult> {
try {
const tx = await this.#backend.begin();
if (parents) {
await this.#backend.remove(tx, this.#buckets.parents, role, parents);
} else {
await this.#backend.del(tx, this.#buckets.parents, role);
}
await this.#backend.end(tx);
return okVoid;
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
await this.#backend.end(tx);
}
async removeRole(role: string): Promise<void> {
const resources = await this.#backend.get(this.#buckets.resources, role);
async removeRole(role: string): Promise<AclResult> {
try {
const v = validateNonEmpty(role, 'Role');
if (v) return v;
const tx = this.#backend.begin();
for (const resource of resources) {
this.#backend.del(tx, allowsBucket(resource), role);
const resources = await this.#backend.get(this.#buckets.resources, role);
const tx = await this.#backend.begin();
for (const resource of resources) {
await this.#backend.del(tx, allowsBucket(resource), role);
}
await this.#backend.del(tx, this.#buckets.resources, role);
await this.#backend.del(tx, this.#buckets.parents, role);
await this.#backend.del(tx, this.#buckets.roles, role);
await this.#backend.remove(tx, this.#buckets.meta, 'roles', role);
await this.#backend.end(tx);
return okVoid;
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
this.#backend.del(tx, this.#buckets.resources, role);
this.#backend.del(tx, this.#buckets.parents, role);
this.#backend.del(tx, this.#buckets.roles, role);
this.#backend.remove(tx, this.#buckets.meta, 'roles', role);
await this.#backend.end(tx);
}
// -------------------------------------------------------------------------
// Resources
// -------------------------------------------------------------------------
async removeResource(resource: string): Promise<void> {
const roles = await this.#backend.get(this.#buckets.meta, 'roles');
const tx = this.#backend.begin();
this.#backend.del(tx, allowsBucket(resource), roles);
for (const role of roles) {
this.#backend.remove(tx, this.#buckets.resources, role, resource);
async removeResource(resource: string): Promise<AclResult> {
try {
const v = validateNonEmpty(resource, 'Resource');
if (v) return v;
const roles = await this.#backend.get(this.#buckets.meta, 'roles');
const tx = await this.#backend.begin();
await this.#backend.del(tx, allowsBucket(resource), roles);
for (const role of roles) {
await this.#backend.remove(tx, this.#buckets.resources, role, resource);
}
await this.#backend.end(tx);
return okVoid;
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
await this.#backend.end(tx);
}
// -------------------------------------------------------------------------
// Remove allow / permissions
// -------------------------------------------------------------------------
async removeAllow(role: string, resources: Values, permissions?: Values): Promise<void> {
const res = toArray(resources);
const perms = permissions ? toArray(permissions) : undefined;
await this.#removePermissions(role, res as string[], perms as string[] | undefined);
async removeAllow(role: string, resources: Values, permissions?: Values): Promise<AclResult> {
try {
const v1 = validateNonEmpty(role, 'Role');
if (v1) return v1;
const v2 = validateNonEmpty(resources, 'Resource');
if (v2) return v2;
const res = toArray(resources);
const perms = permissions ? toArray(permissions) : undefined;
await this.#removePermissions(role, res as string[], perms as string[] | undefined);
return okVoid;
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
async #removePermissions(role: string, resources: string[], permissions?: string[]): Promise<void> {
const tx = this.#backend.begin();
const tx = await this.#backend.begin();
for (const resource of resources) {
const bucket = allowsBucket(resource);
if (permissions) {
this.#backend.remove(tx, bucket, role, permissions);
await 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.del(tx, bucket, role);
await 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();
const tx2 = await 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.remove(tx2, this.#buckets.resources, role, resource);
}
}
await this.#backend.end(tx2);
@ -246,40 +344,68 @@ export class Acl implements IAcl {
// Permission queries
// -------------------------------------------------------------------------
async allowedPermissions(userId: Value, resources: Values): Promise<Record<string, string[]>> {
if (!userId) return {};
async allowedPermissions(userId: Value, resources: Values): Promise<AclResult<Record<string, string[]>>> {
try {
if (!userId) return ok({});
const res = toArray(resources) as string[];
const roles = await this.userRoles(userId);
const result: Record<string, string[]> = {};
const res = toArray(resources) as string[];
const rolesResult = await this.userRoles(userId);
if (!rolesResult.ok) return rolesResult;
for (const resource of res) {
result[resource] = await this.#resourcePermissions(roles, resource);
const roles = rolesResult.data;
const result: Record<string, string[]> = {};
for (const resource of res) {
result[resource] = await this.#resourcePermissions(roles, resource);
}
return ok(result);
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
return result;
}
async isAllowed(userId: Value, resource: string, permissions: Values): Promise<boolean> {
const roles = await this.#backend.get(this.#buckets.users, userId);
if (roles.length === 0) return false;
return this.areAnyRolesAllowed(roles, resource, permissions);
async isAllowed(userId: Value, resource: string, permissions: Values): Promise<AclResult<boolean>> {
try {
const v1 = validateNonEmpty(userId, 'User ID');
if (v1) return v1;
const v2 = validateNonEmpty(resource, 'Resource');
if (v2) return v2;
const v3 = validateNonEmpty(permissions, 'Permission');
if (v3) return v3;
const roles = await this.#backend.get(this.#buckets.users, userId);
if (roles.length === 0) return ok(false);
return this.areAnyRolesAllowed(roles, resource, permissions);
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
async areAnyRolesAllowed(roles: Values, resource: string, permissions: Values): Promise<boolean> {
const rolesArr = toArray(roles) as string[];
if (rolesArr.length === 0) return false;
const permsArr = toArray(permissions) as string[];
return this.#checkPermissions(rolesArr, resource, permsArr);
async areAnyRolesAllowed(roles: Values, resource: string, permissions: Values): Promise<AclResult<boolean>> {
try {
const rolesArr = toArray(roles) as string[];
if (rolesArr.length === 0) return ok(false);
const permsArr = toArray(permissions) as string[];
const allowed = await this.#checkPermissions(rolesArr, resource, permsArr);
return ok(allowed);
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
// -------------------------------------------------------------------------
// What resources
// -------------------------------------------------------------------------
async whatResources(roles: Values, permissions?: Values): Promise<Record<string, string[]> | string[]> {
const rolesArr = toArray(roles) as string[];
const permsArr = permissions ? toArray(permissions) as string[] : undefined;
return this.#permittedResources(rolesArr, permsArr);
async whatResources(roles: Values, permissions?: Values): Promise<AclResult<Record<string, string[]> | string[]>> {
try {
const rolesArr = toArray(roles) as string[];
const permsArr = permissions ? toArray(permissions) as string[] : undefined;
const data = await this.#permittedResources(rolesArr, permsArr);
return ok(data);
} catch (e) {
return err('BACKEND_ERROR', (e as Error).message);
}
}
async #permittedResources(roles: string[], permissions?: string[]): Promise<Record<string, string[]> | string[]> {
@ -321,7 +447,8 @@ export class Acl implements IAcl {
}
async #allUserRoles(userId: Value): Promise<string[]> {
const roles = await this.userRoles(userId);
const result = await this.userRoles(userId);
const roles = result.ok ? result.data : [];
if (roles.length > 0) {
return this.#allRoles(roles);
}

View File

@ -6,6 +6,13 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Acl } from './Acl.js';
import { MemoryBackend } from './data/MemoryBackend.js';
import type { AclResult } from './interfaces.js';
/** Unwrap AclResult — asserts ok and returns data. */
function d<T>(result: AclResult<T>): T {
if (!result.ok) throw new Error(`Expected ok result, got ${result.code}: ${result.message}`);
return result.data;
}
describe('Acl (MemoryBackend)', () => {
let acl: Acl;
@ -22,13 +29,13 @@ describe('Acl (MemoryBackend)', () => {
describe('addUserRoles / userRoles', () => {
it('assigns a single role to a user', async () => {
await acl.addUserRoles('user1', 'admin');
const roles = await acl.userRoles('user1');
const roles = d(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');
const roles = d(await acl.userRoles('user2'));
expect(roles).toEqual(expect.arrayContaining(['editor', 'viewer']));
});
});
@ -37,7 +44,7 @@ describe('Acl (MemoryBackend)', () => {
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');
const roles = d(await acl.userRoles('user1'));
expect(roles).not.toContain('admin');
expect(roles).toContain('editor');
});
@ -47,7 +54,7 @@ describe('Acl (MemoryBackend)', () => {
it('returns users for a given role', async () => {
await acl.addUserRoles('u1', 'admin');
await acl.addUserRoles('u2', 'admin');
const users = await acl.roleUsers('admin');
const users = d(await acl.roleUsers('admin'));
expect(users).toEqual(expect.arrayContaining(['u1', 'u2']));
});
});
@ -55,12 +62,12 @@ describe('Acl (MemoryBackend)', () => {
describe('hasRole', () => {
it('returns true when user has the role', async () => {
await acl.addUserRoles('u1', 'admin');
expect(await acl.hasRole('u1', 'admin')).toBe(true);
expect(d(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);
expect(d(await acl.hasRole('u1', 'admin'))).toBe(false);
});
});
@ -73,24 +80,24 @@ describe('Acl (MemoryBackend)', () => {
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);
expect(d(await acl.isAllowed('u1', 'posts', 'edit'))).toBe(true);
expect(d(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);
expect(d(await acl.isAllowed('u1', 'posts', 'create'))).toBe(true);
expect(d(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);
expect(d(await acl.isAllowed('u1', 'everything', 'anything'))).toBe(true);
expect(d(await acl.isAllowed('u1', 'everything', 'whatever'))).toBe(true);
});
});
@ -107,10 +114,10 @@ describe('Acl (MemoryBackend)', () => {
},
]);
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);
expect(d(await acl.isAllowed('u1', 'profile', 'read'))).toBe(true);
expect(d(await acl.isAllowed('u1', 'profile', 'update'))).toBe(true);
expect(d(await acl.isAllowed('u1', 'feed', 'read'))).toBe(true);
expect(d(await acl.isAllowed('u1', 'feed', 'write'))).toBe(false);
});
});
@ -126,8 +133,8 @@ describe('Acl (MemoryBackend)', () => {
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);
expect(d(await acl.isAllowed('u1', 'docs', 'write'))).toBe(true);
expect(d(await acl.isAllowed('u1', 'docs', 'read'))).toBe(true);
});
it('supports multi-level hierarchy', async () => {
@ -138,9 +145,9 @@ describe('Acl (MemoryBackend)', () => {
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);
expect(d(await acl.isAllowed('u1', 'res', 'admin'))).toBe(true);
expect(d(await acl.isAllowed('u1', 'res', 'write'))).toBe(true);
expect(d(await acl.isAllowed('u1', 'res', 'read'))).toBe(true);
});
});
@ -152,13 +159,13 @@ describe('Acl (MemoryBackend)', () => {
await acl.addUserRoles('u1', 'editor');
// Before removal: editor inherits viewer's read
expect(await acl.isAllowed('u1', 'docs', 'read')).toBe(true);
expect(d(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);
expect(d(await acl.isAllowed('u1', 'docs', 'read'))).toBe(false);
expect(d(await acl.isAllowed('u1', 'docs', 'write'))).toBe(true);
});
});
@ -170,11 +177,11 @@ describe('Acl (MemoryBackend)', () => {
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);
expect(d(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);
expect(d(await acl.areAnyRolesAllowed('temp', 'stuff', 'do'))).toBe(false);
});
});
@ -184,8 +191,8 @@ describe('Acl (MemoryBackend)', () => {
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);
expect(d(await acl.isAllowed('u1', 'posts', 'delete'))).toBe(false);
expect(d(await acl.isAllowed('u1', 'posts', 'read'))).toBe(true);
});
it('removes all permissions for a resource when no perms specified', async () => {
@ -193,8 +200,8 @@ describe('Acl (MemoryBackend)', () => {
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);
expect(d(await acl.isAllowed('u1', 'posts', 'read'))).toBe(false);
expect(d(await acl.isAllowed('u1', 'posts', 'write'))).toBe(false);
});
});
@ -202,10 +209,10 @@ describe('Acl (MemoryBackend)', () => {
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);
expect(d(await acl.isAllowed('u1', 'secrets', 'read'))).toBe(true);
await acl.removeResource('secrets');
expect(await acl.isAllowed('u1', 'secrets', 'read')).toBe(false);
expect(d(await acl.isAllowed('u1', 'secrets', 'read'))).toBe(false);
});
});
@ -219,14 +226,14 @@ describe('Acl (MemoryBackend)', () => {
await acl.allow('editor', 'posts', ['read', 'write']);
await acl.allow('editor', 'comments', 'moderate');
const perms = await acl.allowedPermissions('u1', ['posts', 'comments', 'settings']);
const perms = d(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']);
const perms = d(await acl.allowedPermissions('', ['posts']));
expect(perms).toEqual({});
});
});
@ -234,11 +241,11 @@ describe('Acl (MemoryBackend)', () => {
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);
expect(d(await acl.areAnyRolesAllowed(['a', 'b'], 'res', 'read'))).toBe(true);
});
it('returns false for empty roles', async () => {
expect(await acl.areAnyRolesAllowed([], 'res', 'read')).toBe(false);
expect(d(await acl.areAnyRolesAllowed([], 'res', 'read'))).toBe(false);
});
});
@ -247,7 +254,7 @@ describe('Acl (MemoryBackend)', () => {
await acl.allow('editor', 'posts', ['read', 'write']);
await acl.allow('editor', 'pages', 'read');
const result = await acl.whatResources('editor') as Record<string, string[]>;
const result = d(await acl.whatResources('editor')) as Record<string, string[]>;
expect(Object.keys(result)).toEqual(expect.arrayContaining(['posts', 'pages']));
expect(result['posts']).toEqual(expect.arrayContaining(['read', 'write']));
});
@ -256,7 +263,7 @@ describe('Acl (MemoryBackend)', () => {
await acl.allow('editor', 'posts', ['read', 'write']);
await acl.allow('editor', 'pages', 'read');
const result = await acl.whatResources('editor', 'write') as string[];
const result = d(await acl.whatResources('editor', 'write')) as string[];
expect(result).toContain('posts');
expect(result).not.toContain('pages');
});

View File

@ -11,13 +11,26 @@ export type {
AclGrant,
AclAllow,
AclOptions,
AclErrorCode,
AclOk,
AclErr,
AclResult,
BucketNames,
Value,
Values,
} from './interfaces.js';
export { ok, okVoid, err } 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';
export type { VfsSettings, VfsAclEntry, VfsGroup } from './vfs/vfs-acl.js';
export { DefaultSanitizers } from './vfs/sanitizers.js';
export {
assertNonEmpty,
cleanPath, pathSegments, normalisePath,
cleanPermission, cleanPermissions,
isUuid, cleanUuid, cleanId, cleanGroupName,
sanitizeSubpath, sanitizeWritePath, sanitizeFilename,
} from './vfs/sanitizers.js';

View File

@ -12,6 +12,35 @@
export type Value = string | number;
export type Values = Value | Value[];
// ---------------------------------------------------------------------------
// Result types
// ---------------------------------------------------------------------------
export type AclErrorCode =
| 'OK'
| 'INVALID_INPUT'
| 'NOT_FOUND'
| 'BACKEND_ERROR';
export interface AclOk<T = void> {
readonly ok: true;
readonly code: 'OK';
readonly data: T;
}
export interface AclErr {
readonly ok: false;
readonly code: Exclude<AclErrorCode, 'OK'>;
readonly message: string;
}
export type AclResult<T = void> = AclOk<T> | AclErr;
// Result constructors
export const ok = <T>(data: T): AclOk<T> => ({ ok: true, code: 'OK', data });
export const okVoid: AclOk<void> = Object.freeze({ ok: true, code: 'OK', data: undefined }) as AclOk<void>;
export const err = (code: AclErr['code'], message: string): AclErr => ({ ok: false, code, message });
// ---------------------------------------------------------------------------
// Bucket naming
// ---------------------------------------------------------------------------
@ -43,7 +72,7 @@ export interface AclOptions {
* `T` is the transaction type (e.g. `(() => void)[]` for in-memory).
*/
export interface IBackend<T = unknown> {
begin(): T;
begin(): T | Promise<T>;
end(transaction: T): Promise<void>;
clean(): Promise<void>;
@ -51,9 +80,9 @@ export interface IBackend<T = unknown> {
union(bucket: string, keys: Value[]): Promise<string[]>;
unions(buckets: string[], keys: Value[]): Promise<Record<string, string[]>>;
add(transaction: T, bucket: string, key: Value, values: Values): void;
del(transaction: T, bucket: string, keys: Values): void;
remove(transaction: T, bucket: string, key: Value, values: Values): void;
add(transaction: T, bucket: string, key: Value, values: Values): void | Promise<void>;
del(transaction: T, bucket: string, keys: Values): void | Promise<void>;
remove(transaction: T, bucket: string, key: Value, values: Values): void | Promise<void>;
}
// ---------------------------------------------------------------------------
@ -61,27 +90,27 @@ export interface IBackend<T = unknown> {
// ---------------------------------------------------------------------------
export interface IAcl {
allow(roles: Values, resources: Values, permissions: Values): Promise<void>;
allow(grants: AclGrant[]): Promise<void>;
allow(roles: Values, resources: Values, permissions: Values): Promise<AclResult>;
allow(grants: AclGrant[]): Promise<AclResult>;
addUserRoles(userId: Value, roles: Values): Promise<void>;
removeUserRoles(userId: Value, roles: Values): Promise<void>;
userRoles(userId: Value): Promise<string[]>;
roleUsers(role: Value): Promise<string[]>;
hasRole(userId: Value, role: string): Promise<boolean>;
addUserRoles(userId: Value, roles: Values): Promise<AclResult>;
removeUserRoles(userId: Value, roles: Values): Promise<AclResult>;
userRoles(userId: Value): Promise<AclResult<string[]>>;
roleUsers(role: Value): Promise<AclResult<string[]>>;
hasRole(userId: Value, role: string): Promise<AclResult<boolean>>;
addRoleParents(role: string, parents: Values): Promise<void>;
removeRoleParents(role: string, parents?: Values): Promise<void>;
removeRole(role: string): Promise<void>;
removeResource(resource: string): Promise<void>;
addRoleParents(role: string, parents: Values): Promise<AclResult>;
removeRoleParents(role: string, parents?: Values): Promise<AclResult>;
removeRole(role: string): Promise<AclResult>;
removeResource(resource: string): Promise<AclResult>;
removeAllow(role: string, resources: Values, permissions?: Values): Promise<void>;
removeAllow(role: string, resources: Values, permissions?: Values): Promise<AclResult>;
allowedPermissions(userId: Value, resources: Values): Promise<Record<string, string[]>>;
isAllowed(userId: Value, resource: string, permissions: Values): Promise<boolean>;
areAnyRolesAllowed(roles: Values, resource: string, permissions: Values): Promise<boolean>;
allowedPermissions(userId: Value, resources: Values): Promise<AclResult<Record<string, string[]>>>;
isAllowed(userId: Value, resource: string, permissions: Values): Promise<AclResult<boolean>>;
areAnyRolesAllowed(roles: Values, resource: string, permissions: Values): Promise<AclResult<boolean>>;
whatResources(roles: Values, permissions?: Values): Promise<Record<string, string[]> | string[]>;
whatResources(roles: Values, permissions?: Values): Promise<AclResult<Record<string, string[]> | string[]>>;
}
// ---------------------------------------------------------------------------
@ -103,6 +132,6 @@ export interface AclAllow {
// ---------------------------------------------------------------------------
export interface IFileStore {
read(path?: string): void;
write(path?: string): void;
read(path?: string): void | Promise<void>;
write(path?: string): void | Promise<void>;
}

View File

@ -21,6 +21,7 @@ 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';
import { cleanUuid, sanitizeSubpath } from './sanitizers.js';
export class AclVfsClient {
readonly #acl: Acl;
@ -37,8 +38,8 @@ export class AclVfsClient {
constructor(acl: Acl, ownerId: string, callerId: string, fsOpts: IDefaultParameters) {
this.#acl = acl;
this.#local = new LocalVFS(fsOpts);
this.#ownerId = ownerId;
this.#callerId = callerId;
this.#ownerId = cleanUuid(ownerId);
this.#callerId = cleanUuid(callerId);
}
// ── Guards ──────────────────────────────────────────────────────
@ -49,11 +50,12 @@ export class AclVfsClient {
* This means a grant on `/` covers the entire tree.
*/
async #guard(permission: string, path: string): Promise<void> {
const chain = resourceChain(this.#ownerId, path);
const safePath = sanitizeSubpath(path);
const chain = resourceChain(this.#ownerId, safePath);
for (const resource of chain) {
const allowed = await this.#acl.isAllowed(this.#callerId, resource, permission);
if (allowed) return;
const result = await this.#acl.isAllowed(this.#callerId, resource, permission);
if (result.ok && result.data) return;
}
const err = new Error(

View File

@ -18,6 +18,7 @@ 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';
import { cleanUuid, sanitizeSubpath } from './sanitizers.js';
// ---------------------------------------------------------------------------
// Decorator factory
@ -37,12 +38,12 @@ function aclGuard(permission: string) {
const methodName = String(context.name);
return async function (this: T, ...args: A): Promise<R> {
const path = args[0];
const path = sanitizeSubpath(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) {
const result = await this.acl.isAllowed(this.callerId, resource, permission);
if (result.ok && result.data) {
return target.call(this, ...args);
}
}
@ -75,8 +76,8 @@ export class DecoratedVfsClient {
constructor(acl: Acl, ownerId: string, callerId: string, fsOpts: IDefaultParameters) {
this.acl = acl;
this.local = new LocalVFS(fsOpts);
this.ownerId = ownerId;
this.callerId = callerId;
this.ownerId = cleanUuid(ownerId);
this.callerId = cleanUuid(callerId);
}
// ── Read ────────────────────────────────────────────────────────

View File

@ -38,7 +38,7 @@ export class LocalVFS {
private umask: number;
private ig: any = null;
constructor(fsOptions: IDefaultParameters, resource?: FileResource) {
constructor(fsOptions: IDefaultParameters, _resource?: FileResource) {
if (!fsOptions.root) {
throw new Error('root is a required option');
}
@ -46,10 +46,10 @@ export class LocalVFS {
this.fsOptions = fsOptions;
this.root = pathNormalize(fsOptions.root);
if (pathSep === '/' && this.root[0] !== '/') {
if (pathSep === '/' && !this.root.startsWith('/')) {
throw new Error('root path must start in /');
}
if (this.root[this.root.length - 1] !== pathSep) {
if (!this.root.endsWith(pathSep)) {
this.root += pathSep;
}
@ -125,7 +125,7 @@ export class LocalVFS {
return resolved;
}
async stat(path: string, options?: any): Promise<INode> {
async stat(path: string, _options?: any): Promise<INode> {
const dir = await this.resolvePath(dirname(path));
const file = basename(path);
path = join(dir, file);
@ -182,7 +182,7 @@ export class LocalVFS {
await writeFile(realp, content, options);
}
async readdir(path: string, options?: any): Promise<INode[]> {
async readdir(path: string, _options?: any): Promise<INode[]> {
const realp = await this.resolvePath(path);
const s = await stat(realp);
if (!s.isDirectory()) {
@ -228,38 +228,36 @@ export class LocalVFS {
await mkdir(realp, { ...options, recursive: true });
}
async rmfile(path: string, options?: any): Promise<void> {
async rmfile(path: string, _options?: any): Promise<void> {
const realp = await this.resolvePath(path);
await rm(realp, { recursive: true, force: true });
}
async rmdir(path: string, options: any = {}): Promise<void> {
async rmdir(path: string, _options: any = {}): Promise<void> {
const realp = await this.resolvePath(path);
await rm(realp, { recursive: true, force: true });
}
async rename(from: string, to: string, options: any = {}): Promise<void> {
async rename(from: string, to: string, _options: any = {}): Promise<void> {
const frompath = await this.resolvePath(from);
const topath = await this.resolvePath(to);
await rename(frompath, topath);
}
async copy(from: string, to: string, options: any = {}): Promise<void> {
async copy(from: string, to: string, _options: any = {}): Promise<void> {
const frompath = await this.resolvePath(from);
const topath = await this.resolvePath(to);
await cp(frompath, topath, { recursive: true });
}
exists(path: string): Promise<boolean> {
return new Promise(async (resolve) => {
try {
const realp = await this.resolvePath(path);
await access(realp);
resolve(true);
} catch (err) {
resolve(false);
}
});
async exists(path: string): Promise<boolean> {
try {
const realp = await this.resolvePath(path);
await access(realp);
return true;
} catch (_err) {
return false;
}
}
private calcEtag(s: Stats): string {

View File

@ -1,8 +1,6 @@
// tslint:disable-next-line:interface-name
export interface Hash<T> {
[ id: string ]: T;
[id: string]: T;
}
// tslint:disable-next-line:interface-name
export interface List<T> {
[index: number]: T;
length: number;
@ -16,7 +14,7 @@ export interface IObjectLiteral {
/**
* Represents some Type of the Object.
*/
export type ObjectType<T> = { new (): T } | Function;
export type ObjectType<T> = (new () => T) | ((...args: never[]) => unknown);
/**
* Same as Partial<T> but goes deeper and makes Partial<T> all its properties and sub-properties.
*/
@ -31,10 +29,10 @@ export interface IDelimitter {
export enum EResourceType {
JS_HEADER_INCLUDE = <any> 'JS-HEADER-INCLUDE',
JS_HEADER_SCRIPT_TAG = <any> 'JS-HEADER-SCRIPT-TAG',
CSS = <any> 'CSS',
FILE_PROXY = <any> 'FILE_PROXY'
JS_HEADER_INCLUDE = 'JS-HEADER-INCLUDE' as any,
JS_HEADER_SCRIPT_TAG = 'JS-HEADER-SCRIPT-TAG' as any,
CSS = 'CSS' as any,
FILE_PROXY = 'FILE_PROXY' as any
}
export interface IResource {

View File

@ -6,11 +6,11 @@
* @enum {string}
*/
export enum ENodeType {
FILE = <any>'file',
DIR = <any>'dir',
SYMLINK = <any>'symlink',
OTHER = <any>'other',
BLOCK = <any>'block'
FILE = 'file' as any,
DIR = 'dir' as any,
SYMLINK = 'symlink' as any,
OTHER = 'other' as any,
BLOCK = 'block' as any
}
/**
* General features of a VFS
@ -77,7 +77,6 @@ export type INodeEx = INode & {
linkStat: null;
};
// tslint:disable-next-line:interface-name
export interface VFS_PATH {
mount: string;
path: string;

View File

@ -226,7 +226,7 @@ export function sanitizeWritePath(subpath: string, opts?: { isDirectory?: boolea
* Strips illegal characters, control characters, Windows reserved names.
* Returns the sanitized filename, truncated to 255 bytes.
*/
export function sanitizeFilename(input: string, replacement: string = ''): string {
export function sanitizeFilename(input: string, replacement = ''): string {
if (typeof input !== 'string') {
throw new Error('Input must be a string');
}

View File

@ -0,0 +1,176 @@
/**
* Default input sanitizers for ACL.
*
* Reusable, pure functions no side effects, no I/O.
* Exported as the `DefaultSanitizers` namespace for convenient access.
*
* Used by:
* - Core Acl class (assertNonEmpty)
* - VFS ACL bridge (cleanUuid, cleanGroupName, normalisePath, cleanPermissions)
* - AclVfsClient / DecoratedVfsClient (cleanUuid, sanitizeSubpath)
*/
/**
* Normalise a subpath for VFS use.
*
* - Converts backslashes to forward slashes
* - Strips leading and trailing slashes
* - Collapses consecutive slashes
*
* @example cleanPath('\\docs\\sub/') 'docs/sub'
* @example cleanPath('/') ''
* @example cleanPath('') ''
*/
export function cleanPath(raw: string): string {
return raw
.replace(/\\/g, '/')
.replace(/\/+/g, '/')
.replace(/^\/+/, '')
.replace(/\/+$/, '');
}
/**
* Split a cleaned path into individual segments.
*
* @example pathSegments('docs/sub/file.txt') ['docs', 'sub', 'file.txt']
* @example pathSegments('') []
*/
export function pathSegments(raw: string): string[] {
const clean = cleanPath(raw);
return clean ? clean.split('/') : [];
}
/**
* Normalise a path into its absolute VFS form (leading slash, no trailing).
*
* @example normalisePath('docs/sub/') '/docs/sub'
* @example normalisePath('') '/'
* @example normalisePath('\\a\\b') '/a/b'
*/
export function normalisePath(raw: string): string {
const clean = cleanPath(raw);
return clean ? `/${clean}` : '/';
}
/**
* Validate and normalise a permission name.
*
* - Lowercased
* - Trimmed
* - Rejects empty strings
*
* @throws Error if the permission is empty after trimming.
*/
export function cleanPermission(raw: string): string {
const p = raw.trim().toLowerCase();
if (!p) throw new Error('Permission name cannot be empty');
return p;
}
/**
* Validate and normalise permission arrays.
*/
export function cleanPermissions(raw: string[]): string[] {
return raw.map(cleanPermission);
}
// ---------------------------------------------------------------------------
// UUID validation
// ---------------------------------------------------------------------------
/** Standard UUID v1v5 pattern (case-insensitive, lowercased on output). */
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Test whether a string is a valid UUID.
*
* @example isUuid('3bb4cfbf-318b-44d3-a9d3-35680e738421') true
* @example isUuid('not-a-uuid') false
*/
export function isUuid(value: string): boolean {
return UUID_RE.test(value.trim());
}
/**
* Validate and normalise a UUID string (lowercased, trimmed).
*
* @throws Error if the value is not a valid UUID.
*/
export function cleanUuid(raw: string): string {
const id = raw.trim().toLowerCase();
if (!UUID_RE.test(id)) {
throw new Error(`Invalid UUID: '${raw}'`);
}
return id;
}
/**
* Validate a user/owner identifier must be a valid UUID.
*
* @throws Error if the identifier is not a valid UUID.
*/
export function cleanId(raw: string): string {
return cleanUuid(raw);
}
/**
* Validate a group name (non-empty, lowercased, no colons).
*
* @throws Error if the name is empty or contains reserved characters.
*/
export function cleanGroupName(raw: string): string {
const name = raw.trim().toLowerCase();
if (!name) throw new Error('Group name cannot be empty');
if (name.includes(':')) throw new Error(`Group name cannot contain ':': ${name}`);
return name;
}
// ---------------------------------------------------------------------------
// Generic assertions (used by core Acl)
// ---------------------------------------------------------------------------
/**
* Reject empty/whitespace-only identifiers.
* Works with single values or arrays of values (string | number).
*
* @throws Error with a descriptive label if any value is empty.
*
* @example assertNonEmpty('admin', 'Role') // ok
* @example assertNonEmpty('', 'Role') // throws "Role cannot be empty"
* @example assertNonEmpty(['a', ''], 'Role') // throws "Role cannot be empty"
*/
export function assertNonEmpty(value: string | number | (string | number)[], label: string): void {
const arr = Array.isArray(value) ? value : [value];
for (const v of arr) {
const s = String(v).trim();
if (!s) throw new Error(`${label} cannot be empty`);
}
}
// ---------------------------------------------------------------------------
// Re-export path-sanitizer functions for unified access
// ---------------------------------------------------------------------------
export { sanitizeSubpath, sanitizeWritePath, sanitizeFilename } from './path-sanitizer.js';
// ---------------------------------------------------------------------------
// Namespace re-export for convenience
// ---------------------------------------------------------------------------
import { sanitizeSubpath, sanitizeWritePath, sanitizeFilename } from './path-sanitizer.js';
export const DefaultSanitizers = {
assertNonEmpty,
cleanPath,
pathSegments,
normalisePath,
cleanPermission,
cleanPermissions,
isUuid,
cleanUuid,
cleanId,
cleanGroupName,
sanitizeSubpath,
sanitizeWritePath,
sanitizeFilename,
} as const;

View File

@ -4,6 +4,10 @@
* Reads per-user `vfs-settings.json` files and populates an Acl instance
* with fine-grained, path-scoped permissions for each user's VFS folder.
*
* Supports:
* - Direct user grants: { userId, path, permissions }
* - Group grants: { group, path, permissions }
*
* Resource naming: `vfs:<ownerId>:<path>` e.g. `vfs:3bb4cfbf-...:/docs`
*
* Permissions: read | write | list | mkdir | delete | rename | copy
@ -11,13 +15,22 @@
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import type { Acl } from '../Acl.js';
import { pathSegments, normalisePath, cleanGroupName, cleanId, cleanPermissions } from './sanitizers.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface VfsGroup {
name: string;
members: string[];
}
export interface VfsAclEntry {
userId: string;
/** Direct user grant */
userId?: string;
/** Group grant */
group?: string;
/** Scoped path — defaults to "/" (entire folder). */
path?: string;
permissions: string[];
@ -25,6 +38,7 @@ export interface VfsAclEntry {
export interface VfsSettings {
owner: string;
groups?: VfsGroup[];
acl: VfsAclEntry[];
}
@ -39,7 +53,7 @@ export interface VfsSettings {
* vfsResource('aaa', '/docs') 'vfs:aaa:/docs'
*/
export function vfsResource(ownerId: string, resourcePath = '/'): string {
return `vfs:${ownerId}:${resourcePath}`;
return `vfs:${ownerId}:${normalisePath(resourcePath)}`;
}
/**
@ -51,8 +65,7 @@ export function vfsResource(ownerId: string, resourcePath = '/'): string {
* 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 segments = pathSegments(subpath);
const chain: string[] = [];
@ -76,6 +89,7 @@ export function resourceChain(ownerId: string, subpath: string): string[] {
*
* - The **owner** always gets `*` on `/` (entire tree).
* - Each ACL entry grants permissions scoped to `entry.path` (default: `/`).
* - Group entries resolve members from the `groups[]` array.
*/
export async function loadVfsSettings(acl: Acl, userDir: string): Promise<VfsSettings | null> {
const settingsPath = join(userDir, 'vfs-settings.json');
@ -84,18 +98,50 @@ export async function loadVfsSettings(acl: Acl, userDir: string): Promise<VfsSet
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);
// Validate owner
const safeOwner = cleanId(settings.owner);
// Granted users
// Helper: unwrap result or throw
const unwrap = (result: { ok: boolean; message?: string }): void => {
if (!result.ok) throw new Error((result as { message: string }).message);
};
// Owner role — full access on entire tree
const ownerRole = `owner:${safeOwner}`;
unwrap(await acl.allow(ownerRole, vfsResource(safeOwner, '/'), '*'));
unwrap(await acl.addUserRoles(safeOwner, ownerRole));
// Index groups (validate member IDs)
const groupMembers = new Map<string, string[]>();
for (const group of settings.groups ?? []) {
const safeName = cleanGroupName(group.name);
const safeMembers = group.members.map(m => cleanId(m));
groupMembers.set(safeName, safeMembers);
}
// Process ACL entries
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);
const resourcePath = normalisePath(entry.path ?? '/');
const resource = vfsResource(safeOwner, resourcePath);
const safePerms = cleanPermissions(entry.permissions);
if (entry.group) {
// Group grant
const safeGroup = cleanGroupName(entry.group);
const groupRole = `group:${safeOwner}:${safeGroup}`;
unwrap(await acl.allow(groupRole, resource, safePerms));
const members = groupMembers.get(safeGroup) ?? [];
for (const memberId of members) {
unwrap(await acl.addUserRoles(memberId, groupRole));
}
} else if (entry.userId) {
// Direct user grant
const safeId = cleanId(entry.userId);
const grantRole = `vfs-grant:${safeOwner}:${safeId}:${resourcePath}`;
unwrap(await acl.allow(grantRole, resource, safePerms));
unwrap(await acl.addUserRoles(safeId, grantRole));
}
}
return settings;

View File

@ -0,0 +1,166 @@
/**
* DecoratedVfsClient e2e test
*
* Verifies that the decorator-based client behaves identically
* to the imperative AclVfsClient using the same path-scoped fixture.
*
* Uses: tests/vfs/root/3bb4cfbf-/vfs-settings.json
* - User aaa read+list on /
* - User ccc full on /shared, read+list on /docs
*/
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 { DecoratedVfsClient } from '../src/vfs/DecoratedVfsClient.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OWNER = '3bb4cfbf-318b-44d3-a9d3-35680e738421';
const READER = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb';
const COLLAB = 'cccccccc-4444-5555-6666-dddddddddddd';
const NOBODY = '99999999-0000-0000-0000-ffffffffffff';
const USER_DIR = resolve(import.meta.dirname!, 'vfs/root', OWNER);
const SANDBOX = '_decorated_sandbox';
function client(acl: Acl, callerId: string): DecoratedVfsClient {
return new DecoratedVfsClient(acl, OWNER, callerId, { root: USER_DIR });
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('DecoratedVfsClient — e2e', () => {
let acl: Acl;
beforeAll(async () => {
acl = new Acl(new MemoryBackend());
await loadVfsSettings(acl, USER_DIR);
for (const sub of ['shared', 'docs', 'private']) {
mkdirSync(join(USER_DIR, sub, SANDBOX), { 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 access
// =====================================================================
describe('owner', () => {
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 in /private', async () => {
const c = client(acl, OWNER);
await c.writefile(`private/${SANDBOX}/decorated.txt`, 'decorator works');
expect(readFileSync(join(USER_DIR, 'private', SANDBOX, 'decorated.txt'), 'utf8'))
.toBe('decorator works');
});
it('can mkdir in /shared', async () => {
const c = client(acl, OWNER);
await c.mkdir(`shared/${SANDBOX}/dec-dir`);
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'dec-dir'))).toBe(true);
});
it('can delete in /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 /
// =====================================================================
describe('reader (read+list on /)', () => {
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 check existence in /shared', async () => {
const c = client(acl, READER);
expect(await c.exists('shared/data.txt')).toBe(true);
});
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 /docs', async () => {
const c = client(acl, READER);
await expect(c.mkdir(`docs/${SANDBOX}/nope`)).rejects.toThrow('EACCES');
});
});
// =====================================================================
// Collaborator — full on /shared, read on /docs
// =====================================================================
describe('collaborator (full on /shared)', () => {
it('can write in /shared', async () => {
const c = client(acl, COLLAB);
await c.writefile(`shared/${SANDBOX}/collab.txt`, 'collab');
expect(readFileSync(join(USER_DIR, 'shared', SANDBOX, 'collab.txt'), 'utf8'))
.toBe('collab');
});
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('CANNOT write in /docs', async () => {
const c = client(acl, COLLAB);
await expect(c.writefile(`docs/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT list /private', async () => {
const c = client(acl, COLLAB);
await expect(c.readdir('private')).rejects.toThrow('EACCES');
});
});
// =====================================================================
// Stranger — blocked
// =====================================================================
describe('stranger — EACCES on everything', () => {
it('CANNOT list /shared', async () => {
const c = client(acl, NOBODY);
await expect(c.readdir('shared')).rejects.toThrow('EACCES');
});
it('CANNOT read /docs', async () => {
const c = client(acl, NOBODY);
await expect(c.exists('docs/readme.txt')).rejects.toThrow('EACCES');
});
it('CANNOT write anywhere', async () => {
const c = client(acl, NOBODY);
await expect(c.writefile(`shared/${SANDBOX}/x.txt`, 'x')).rejects.toThrow('EACCES');
});
});
});

View File

@ -0,0 +1,189 @@
/**
* VFS ACL Edge cases
*
* Fixture: tests/vfs/root/edge-cases/vfs-settings.json
*
* 1. Empty group "empty-group" has members: [] full on /shared
* Nobody should get access, but the loader must not crash.
*
* 2. Undefined group ref "ghost-group" is referenced in ACL
* but NOT defined in groups[]. Loader should handle gracefully.
*
* 3. Multi-group user user "aaa" is in both "multi-a" and "multi-b".
* "multi-a" grants read+list on /shared.
* "multi-b" grants write+mkdir on /shared and read+list on /docs.
* User should get the UNION: read+list+write+mkdir on /shared AND read+list on /docs.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { resolve, join } from 'node:path';
import { existsSync, rmSync, mkdirSync, writeFileSync } from 'node:fs';
import { Acl } from '../src/Acl.js';
import { MemoryBackend } from '../src/data/MemoryBackend.js';
import { loadVfsSettings, vfsResource } from '../src/vfs/vfs-acl.js';
import { AclVfsClient } from '../src/vfs/AclVfsClient.js';
import type { AclResult } from '../src/interfaces.js';
/** Unwrap AclResult — asserts ok and returns data. */
function d<T>(result: AclResult<T>): T {
if (!result.ok) throw new Error(`Expected ok, got ${result.code}: ${result.message}`);
return result.data;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OWNER = '3bb4cfbf-318b-44d3-a9d3-35680e738421';
const USER_A = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'; // in multi-a + multi-b
const NOBODY = '99999999-0000-0000-0000-ffffffffffff';
const USER_DIR = resolve(import.meta.dirname!, 'vfs/root/edge-cases');
const SANDBOX = '_edge_sandbox';
const SHARED = vfsResource(OWNER, '/shared');
const DOCS = vfsResource(OWNER, '/docs');
function client(acl: Acl, callerId: string): AclVfsClient {
return new AclVfsClient(acl, OWNER, callerId, { root: USER_DIR });
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('VFS ACL — edge cases', () => {
let acl: Acl;
beforeAll(async () => {
acl = new Acl(new MemoryBackend());
// Create folder structure + seed files
for (const sub of ['shared', 'docs']) {
mkdirSync(join(USER_DIR, sub, SANDBOX), { recursive: true });
}
writeFileSync(join(USER_DIR, 'shared', 'data.txt'), 'shared');
writeFileSync(join(USER_DIR, 'docs', 'readme.txt'), 'docs');
await loadVfsSettings(acl, USER_DIR);
});
afterAll(() => {
for (const sub of ['shared', 'docs']) {
const p = join(USER_DIR, sub, SANDBOX);
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
}
for (const f of ['shared/data.txt', 'docs/readme.txt']) {
const p = join(USER_DIR, f);
if (existsSync(p)) rmSync(p);
}
});
// =================================================================
// 1. Empty group — members: []
// =================================================================
describe('empty group — nobody gets access', () => {
it('loader does not crash', () => {
// If we got here, loadVfsSettings succeeded
expect(true).toBe(true);
});
it('stranger has no access to /shared (despite empty group having full perms)', async () => {
expect(d(await acl.isAllowed(NOBODY, SHARED, 'read'))).toBe(false);
expect(d(await acl.isAllowed(NOBODY, SHARED, 'write'))).toBe(false);
});
it('stranger cannot list /shared via client', async () => {
const c = client(acl, NOBODY);
await expect(c.readdir('shared')).rejects.toThrow('EACCES');
});
});
// =================================================================
// 2. Undefined group ref — "ghost-group" not in groups[]
// =================================================================
describe('undefined group ref — graceful handling', () => {
it('loader does not crash on unknown group name', () => {
expect(true).toBe(true);
});
it('nobody gets the ghost-group permissions on /docs', async () => {
// The ghost-group granted read+list on /docs, but has no members
expect(d(await acl.isAllowed(NOBODY, DOCS, 'read'))).toBe(false);
});
});
// =================================================================
// 3. Multi-group user — permissions merge
// =================================================================
describe('multi-group user (aaa) — permissions merge across groups', () => {
// From multi-a: read + list on /shared
// From multi-b: write + mkdir on /shared, read + list on /docs
it('has read on /shared (from multi-a)', async () => {
expect(d(await acl.isAllowed(USER_A, SHARED, 'read'))).toBe(true);
});
it('has list on /shared (from multi-a)', async () => {
expect(d(await acl.isAllowed(USER_A, SHARED, 'list'))).toBe(true);
});
it('has write on /shared (from multi-b)', async () => {
expect(d(await acl.isAllowed(USER_A, SHARED, 'write'))).toBe(true);
});
it('has mkdir on /shared (from multi-b)', async () => {
expect(d(await acl.isAllowed(USER_A, SHARED, 'mkdir'))).toBe(true);
});
it('does NOT have delete on /shared (neither group grants it)', async () => {
expect(d(await acl.isAllowed(USER_A, SHARED, 'delete'))).toBe(false);
});
it('has read+list on /docs (from multi-b)', async () => {
expect(d(await acl.isAllowed(USER_A, DOCS, 'read'))).toBe(true);
expect(d(await acl.isAllowed(USER_A, DOCS, 'list'))).toBe(true);
});
it('does NOT have write on /docs', async () => {
expect(d(await acl.isAllowed(USER_A, DOCS, 'write'))).toBe(false);
});
// Real FS ops
it('can list /shared via client', async () => {
const c = client(acl, USER_A);
const entries = await c.readdir('shared');
expect(entries.map(e => e.name)).toContain('data.txt');
});
it('can write in /shared via client', async () => {
const c = client(acl, USER_A);
await c.writefile(`shared/${SANDBOX}/merged.txt`, 'from both groups');
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'merged.txt'))).toBe(true);
});
it('can mkdir in /shared via client', async () => {
const c = client(acl, USER_A);
await c.mkdir(`shared/${SANDBOX}/merged-dir`);
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'merged-dir'))).toBe(true);
});
it('CANNOT delete in /shared via client', async () => {
const c = client(acl, USER_A);
await expect(c.rmfile(`shared/${SANDBOX}/merged.txt`)).rejects.toThrow('EACCES');
});
it('can list /docs via client', async () => {
const c = client(acl, USER_A);
const entries = await c.readdir('docs');
expect(entries.map(e => e.name)).toContain('readme.txt');
});
it('CANNOT write in /docs via client', async () => {
const c = client(acl, USER_A);
await expect(c.writefile(`docs/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
});
});

View File

@ -0,0 +1,276 @@
/**
* VFS ACL Groups e2e test
*
* Fixture: tests/vfs/root/groups-test/vfs-settings.json
*
* Groups:
* "team" [aaa, ccc]
* "viewers" [ddd, fff]
*
* ACL:
* group:"team" /shared read, write, list, mkdir, delete
* group:"team" /docs read, list
* group:"viewers" /docs read, list
* userId: fff /shared read, list (direct override for one viewer)
*
* Expected:
* - aaa (team member): full on /shared, read on /docs, nothing on /private
* - ccc (team member): same as aaa
* - ddd (viewer only): read /docs, nothing on /shared, nothing on /private
* - fff (viewer + direct): read /docs, read+list /shared (via direct), nothing on /private
* - stranger: nothing anywhere
*/
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, vfsResource } from '../src/vfs/vfs-acl.js';
import { AclVfsClient } from '../src/vfs/AclVfsClient.js';
import type { AclResult } from '../src/interfaces.js';
/** Unwrap AclResult — asserts ok and returns data. */
function d<T>(result: AclResult<T>): T {
if (!result.ok) throw new Error(`Expected ok, got ${result.code}: ${result.message}`);
return result.data;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const OWNER = '3bb4cfbf-318b-44d3-a9d3-35680e738421';
const TEAM_A = 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb';
const TEAM_C = 'cccccccc-4444-5555-6666-dddddddddddd';
const VIEW_D = 'dddddddd-7777-8888-9999-eeeeeeeeeeee';
const VIEW_F = 'ffffffff-aaaa-bbbb-cccc-111111111111'; // also has direct grant
const NOBODY = '99999999-0000-0000-0000-ffffffffffff';
const USER_DIR = resolve(import.meta.dirname!, 'vfs/root/groups-test');
const SANDBOX = '_group_sandbox';
function client(acl: Acl, callerId: string): AclVfsClient {
return new AclVfsClient(acl, OWNER, callerId, { root: USER_DIR });
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('VFS ACL — Groups e2e', () => {
let acl: Acl;
beforeAll(async () => {
acl = new Acl(new MemoryBackend());
// Create folder structure
for (const sub of ['shared', 'docs', 'private']) {
mkdirSync(join(USER_DIR, sub, SANDBOX), { recursive: true });
}
// Seed files
const fs = await import('node:fs');
fs.writeFileSync(join(USER_DIR, 'docs', 'readme.txt'), 'hello docs');
fs.writeFileSync(join(USER_DIR, 'shared', 'data.txt'), 'shared data');
fs.writeFileSync(join(USER_DIR, 'private', 'secret.txt'), 'top secret');
await loadVfsSettings(acl, USER_DIR);
});
afterAll(() => {
for (const sub of ['shared', 'docs', 'private']) {
const p = join(USER_DIR, sub, SANDBOX);
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
}
// Clean seeded files
for (const f of ['docs/readme.txt', 'shared/data.txt', 'private/secret.txt']) {
const p = join(USER_DIR, f);
if (existsSync(p)) rmSync(p);
}
});
// =================================================================
// Raw ACL checks (permission boundary)
// =================================================================
describe('raw ACL checks', () => {
const SHARED = vfsResource(OWNER, '/shared');
const DOCS = vfsResource(OWNER, '/docs');
const PRIVATE = vfsResource(OWNER, '/private');
const ROOT = vfsResource(OWNER, '/');
it('owner has wildcard on everything', async () => {
expect(d(await acl.isAllowed(OWNER, ROOT, 'read'))).toBe(true);
expect(d(await acl.isAllowed(OWNER, ROOT, 'write'))).toBe(true);
expect(d(await acl.isAllowed(OWNER, ROOT, 'delete'))).toBe(true);
expect(d(await acl.isAllowed(OWNER, ROOT, 'some-future-perm'))).toBe(true);
});
it('team member (aaa) — full on /shared', async () => {
expect(d(await acl.isAllowed(TEAM_A, SHARED, 'read'))).toBe(true);
expect(d(await acl.isAllowed(TEAM_A, SHARED, 'write'))).toBe(true);
expect(d(await acl.isAllowed(TEAM_A, SHARED, 'list'))).toBe(true);
expect(d(await acl.isAllowed(TEAM_A, SHARED, 'mkdir'))).toBe(true);
expect(d(await acl.isAllowed(TEAM_A, SHARED, 'delete'))).toBe(true);
});
it('team member (aaa) — read+list on /docs', async () => {
expect(d(await acl.isAllowed(TEAM_A, DOCS, 'read'))).toBe(true);
expect(d(await acl.isAllowed(TEAM_A, DOCS, 'list'))).toBe(true);
expect(d(await acl.isAllowed(TEAM_A, DOCS, 'write'))).toBe(false);
});
it('team member (aaa) — no access to /private', async () => {
expect(d(await acl.isAllowed(TEAM_A, PRIVATE, 'read'))).toBe(false);
});
it('team member (ccc) — same as aaa', async () => {
expect(d(await acl.isAllowed(TEAM_C, SHARED, 'write'))).toBe(true);
expect(d(await acl.isAllowed(TEAM_C, DOCS, 'list'))).toBe(true);
expect(d(await acl.isAllowed(TEAM_C, PRIVATE, 'read'))).toBe(false);
});
it('viewer (ddd) — read+list on /docs only', async () => {
expect(d(await acl.isAllowed(VIEW_D, DOCS, 'read'))).toBe(true);
expect(d(await acl.isAllowed(VIEW_D, DOCS, 'list'))).toBe(true);
expect(d(await acl.isAllowed(VIEW_D, DOCS, 'write'))).toBe(false);
expect(d(await acl.isAllowed(VIEW_D, SHARED, 'read'))).toBe(false);
expect(d(await acl.isAllowed(VIEW_D, PRIVATE, 'read'))).toBe(false);
});
it('viewer+direct (fff) — read /docs via group + read+list /shared via direct', async () => {
// From viewers group
expect(d(await acl.isAllowed(VIEW_F, DOCS, 'read'))).toBe(true);
expect(d(await acl.isAllowed(VIEW_F, DOCS, 'list'))).toBe(true);
// From direct grant
expect(d(await acl.isAllowed(VIEW_F, SHARED, 'read'))).toBe(true);
expect(d(await acl.isAllowed(VIEW_F, SHARED, 'list'))).toBe(true);
// Not granted
expect(d(await acl.isAllowed(VIEW_F, SHARED, 'write'))).toBe(false);
expect(d(await acl.isAllowed(VIEW_F, PRIVATE, 'read'))).toBe(false);
});
it('stranger — nothing anywhere', async () => {
expect(d(await acl.isAllowed(NOBODY, SHARED, 'read'))).toBe(false);
expect(d(await acl.isAllowed(NOBODY, DOCS, 'list'))).toBe(false);
expect(d(await acl.isAllowed(NOBODY, PRIVATE, 'read'))).toBe(false);
expect(d(await acl.isAllowed(NOBODY, ROOT, 'read'))).toBe(false);
});
});
// =================================================================
// Real filesystem through AclVfsClient
// =================================================================
describe('team member (aaa) — real FS ops', () => {
it('can list /shared', async () => {
const c = client(acl, TEAM_A);
const entries = await c.readdir('shared');
expect(entries.map(e => e.name)).toContain('data.txt');
});
it('can write in /shared', async () => {
const c = client(acl, TEAM_A);
await c.writefile(`shared/${SANDBOX}/team.txt`, 'from team');
expect(readFileSync(join(USER_DIR, 'shared', SANDBOX, 'team.txt'), 'utf8')).toBe('from team');
});
it('can mkdir in /shared', async () => {
const c = client(acl, TEAM_A);
await c.mkdir(`shared/${SANDBOX}/team-dir`);
expect(existsSync(join(USER_DIR, 'shared', SANDBOX, 'team-dir'))).toBe(true);
});
it('can delete in /shared', async () => {
const c = client(acl, TEAM_A);
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);
});
it('can list /docs (read-only)', async () => {
const c = client(acl, TEAM_A);
const entries = await c.readdir('docs');
expect(entries.map(e => e.name)).toContain('readme.txt');
});
it('CANNOT write in /docs', async () => {
const c = client(acl, TEAM_A);
await expect(c.writefile(`docs/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT list /private', async () => {
const c = client(acl, TEAM_A);
await expect(c.readdir('private')).rejects.toThrow('EACCES');
});
});
describe('viewer (ddd) — real FS ops', () => {
it('can list /docs', async () => {
const c = client(acl, VIEW_D);
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, VIEW_D);
expect(await c.exists('docs/readme.txt')).toBe(true);
});
it('CANNOT list /shared', async () => {
const c = client(acl, VIEW_D);
await expect(c.readdir('shared')).rejects.toThrow('EACCES');
});
it('CANNOT write in /docs', async () => {
const c = client(acl, VIEW_D);
await expect(c.writefile(`docs/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
});
describe('viewer+direct (fff) — mixed grants FS ops', () => {
it('can list /docs (from group)', async () => {
const c = client(acl, VIEW_F);
const entries = await c.readdir('docs');
expect(entries.map(e => e.name)).toContain('readme.txt');
});
it('can list /shared (from direct grant)', async () => {
const c = client(acl, VIEW_F);
const entries = await c.readdir('shared');
expect(entries.map(e => e.name)).toContain('data.txt');
});
it('can check existence in /shared (from direct grant)', async () => {
const c = client(acl, VIEW_F);
expect(await c.exists('shared/data.txt')).toBe(true);
});
it('CANNOT write in /shared (direct only grants read+list)', async () => {
const c = client(acl, VIEW_F);
await expect(c.writefile(`shared/${SANDBOX}/nope.txt`, 'x')).rejects.toThrow('EACCES');
});
it('CANNOT access /private', async () => {
const c = client(acl, VIEW_F);
await expect(c.readdir('private')).rejects.toThrow('EACCES');
});
});
describe('stranger — blocked on everything', () => {
it('CANNOT list /shared', async () => {
const c = client(acl, NOBODY);
await expect(c.readdir('shared')).rejects.toThrow('EACCES');
});
it('CANNOT read /docs', async () => {
const c = client(acl, NOBODY);
await expect(c.exists('docs/readme.txt')).rejects.toThrow('EACCES');
});
it('CANNOT write anywhere', async () => {
const c = client(acl, NOBODY);
await expect(c.writefile(`shared/${SANDBOX}/x.txt`, 'x')).rejects.toThrow('EACCES');
});
});
});

View File

@ -15,6 +15,13 @@ 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';
import type { AclResult } from '../src/interfaces.js';
/** Unwrap AclResult — asserts ok and returns data. */
function d<T>(result: AclResult<T>): T {
if (!result.ok) throw new Error(`Expected ok, got ${result.code}: ${result.message}`);
return result.data;
}
// ---------------------------------------------------------------------------
// Constants
@ -53,13 +60,13 @@ describe('VFS ACL — e2e', () => {
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);
expect(d(await acl.isAllowed(OWNER_ID, ROOT, 'read'))).toBe(true);
expect(d(await acl.isAllowed(OWNER_ID, ROOT, 'write'))).toBe(true);
expect(d(await acl.isAllowed(OWNER_ID, ROOT, 'list'))).toBe(true);
expect(d(await acl.isAllowed(OWNER_ID, ROOT, 'mkdir'))).toBe(true);
expect(d(await acl.isAllowed(OWNER_ID, ROOT, 'delete'))).toBe(true);
expect(d(await acl.isAllowed(OWNER_ID, ROOT, 'rename'))).toBe(true);
expect(d(await acl.isAllowed(OWNER_ID, ROOT, 'copy'))).toBe(true);
});
});
@ -69,27 +76,27 @@ describe('VFS ACL — e2e', () => {
describe(`read-only user (${READ_ONLY_USER})`, () => {
it('can read on /', async () => {
expect(await acl.isAllowed(READ_ONLY_USER, ROOT, 'read')).toBe(true);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(await acl.isAllowed(READ_ONLY_USER, ROOT, 'rename'))).toBe(false);
});
});
@ -102,61 +109,61 @@ describe('VFS ACL — e2e', () => {
describe(`full-grant user (${FULL_GRANT_USER})`, () => {
it('can read on /shared', async () => {
expect(await acl.isAllowed(FULL_GRANT_USER, SHARED, 'read')).toBe(true);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(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);
expect(d(await acl.isAllowed(FULL_GRANT_USER, ROOT, 'read'))).toBe(false);
});
});
@ -167,16 +174,16 @@ describe('VFS ACL — e2e', () => {
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);
expect(d(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);
expect(d(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);
expect(d(await acl.isAllowed(STRANGER_USER, DOCS, 'list'))).toBe(false);
});
});
});

View File

@ -0,0 +1,66 @@
{
"owner": "3bb4cfbf-318b-44d3-a9d3-35680e738421",
"groups": [
{
"name": "empty-group",
"members": []
},
{
"name": "multi-a",
"members": [
"aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb"
]
},
{
"name": "multi-b",
"members": [
"aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb"
]
}
],
"acl": [
{
"group": "empty-group",
"path": "/shared",
"permissions": [
"read",
"write",
"list",
"mkdir",
"delete"
]
},
{
"group": "ghost-group",
"path": "/docs",
"permissions": [
"read",
"list"
]
},
{
"group": "multi-a",
"path": "/shared",
"permissions": [
"read",
"list"
]
},
{
"group": "multi-b",
"path": "/shared",
"permissions": [
"write",
"mkdir"
]
},
{
"group": "multi-b",
"path": "/docs",
"permissions": [
"read",
"list"
]
}
]
}

View File

@ -0,0 +1,56 @@
{
"owner": "3bb4cfbf-318b-44d3-a9d3-35680e738421",
"groups": [
{
"name": "team",
"members": [
"aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb",
"cccccccc-4444-5555-6666-dddddddddddd"
]
},
{
"name": "viewers",
"members": [
"dddddddd-7777-8888-9999-eeeeeeeeeeee",
"ffffffff-aaaa-bbbb-cccc-111111111111"
]
}
],
"acl": [
{
"group": "team",
"path": "/shared",
"permissions": [
"read",
"write",
"list",
"mkdir",
"delete"
]
},
{
"group": "team",
"path": "/docs",
"permissions": [
"read",
"list"
]
},
{
"group": "viewers",
"path": "/docs",
"permissions": [
"read",
"list"
]
},
{
"userId": "ffffffff-aaaa-bbbb-cccc-111111111111",
"path": "/shared",
"permissions": [
"read",
"list"
]
}
]
}

View File

@ -7,6 +7,11 @@
"composite": false,
"inlineSourceMap": true,
"strict": true,
"alwaysStrict": true,
"allowUnusedLabels": false,
"noUnusedParameters": true,
"noImplicitReturns": true,
"allowUnreachableCode": false,
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": [