acl:groups, tests, async, lint
This commit is contained in:
parent
952d125f48
commit
4d1eade6c0
27
packages/acl/.gitignore
vendored
27
packages/acl/.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
27
packages/acl/dist-in/Acl.d.ts
vendored
Normal 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
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
1
packages/acl/dist-in/acl.test.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
export {};
|
||||
225
packages/acl/dist-in/acl.test.js
Normal file
225
packages/acl/dist-in/acl.test.js
Normal file
File diff suppressed because one or more lines are too long
10
packages/acl/dist-in/data/FileBackend.d.ts
vendored
Normal file
10
packages/acl/dist-in/data/FileBackend.d.ts
vendored
Normal 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;
|
||||
}
|
||||
44
packages/acl/dist-in/data/FileBackend.js
Normal file
44
packages/acl/dist-in/data/FileBackend.js
Normal 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=
|
||||
25
packages/acl/dist-in/data/MemoryBackend.d.ts
vendored
Normal file
25
packages/acl/dist-in/data/MemoryBackend.d.ts
vendored
Normal 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 {};
|
||||
119
packages/acl/dist-in/data/MemoryBackend.js
Normal file
119
packages/acl/dist-in/data/MemoryBackend.js
Normal file
File diff suppressed because one or more lines are too long
14
packages/acl/dist-in/index.d.ts
vendored
Normal file
14
packages/acl/dist-in/index.d.ts
vendored
Normal 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';
|
||||
14
packages/acl/dist-in/index.js
Normal file
14
packages/acl/dist-in/index.js
Normal 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
80
packages/acl/dist-in/interfaces.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
11
packages/acl/dist-in/interfaces.js
Normal file
11
packages/acl/dist-in/interfaces.js
Normal 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
|
||||
46
packages/acl/dist-in/vfs/AclVfsClient.d.ts
vendored
Normal file
46
packages/acl/dist-in/vfs/AclVfsClient.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
88
packages/acl/dist-in/vfs/AclVfsClient.js
Normal file
88
packages/acl/dist-in/vfs/AclVfsClient.js
Normal 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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
|
||||
46
packages/acl/dist-in/vfs/DecoratedVfsClient.d.ts
vendored
Normal file
46
packages/acl/dist-in/vfs/DecoratedVfsClient.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
100
packages/acl/dist-in/vfs/DecoratedVfsClient.js
Normal file
100
packages/acl/dist-in/vfs/DecoratedVfsClient.js
Normal 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
49
packages/acl/dist-in/vfs/fs/Local.d.ts
vendored
Normal 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;
|
||||
}
|
||||
217
packages/acl/dist-in/vfs/fs/Local.js
Normal file
217
packages/acl/dist-in/vfs/fs/Local.js
Normal file
File diff suppressed because one or more lines are too long
55
packages/acl/dist-in/vfs/fs/Resource.d.ts
vendored
Normal file
55
packages/acl/dist-in/vfs/fs/Resource.d.ts
vendored
Normal 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;
|
||||
14
packages/acl/dist-in/vfs/fs/Resource.js
Normal file
14
packages/acl/dist-in/vfs/fs/Resource.js
Normal 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
115
packages/acl/dist-in/vfs/fs/VFS.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
88
packages/acl/dist-in/vfs/fs/VFS.js
Normal file
88
packages/acl/dist-in/vfs/fs/VFS.js
Normal 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
|
||||
2
packages/acl/dist-in/vfs/fs/index.d.ts
vendored
Normal file
2
packages/acl/dist-in/vfs/fs/index.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './Local.js';
|
||||
export * from './VFS.js';
|
||||
3
packages/acl/dist-in/vfs/fs/index.js
Normal file
3
packages/acl/dist-in/vfs/fs/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './Local.js';
|
||||
export * from './VFS.js';
|
||||
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvdmZzL2ZzL2luZGV4LnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLGNBQWMsWUFBWSxDQUFDO0FBQzNCLGNBQWMsVUFBVSxDQUFDIn0=
|
||||
53
packages/acl/dist-in/vfs/path-sanitizer.d.ts
vendored
Normal file
53
packages/acl/dist-in/vfs/path-sanitizer.d.ts
vendored
Normal 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;
|
||||
208
packages/acl/dist-in/vfs/path-sanitizer.js
Normal file
208
packages/acl/dist-in/vfs/path-sanitizer.js
Normal file
File diff suppressed because one or more lines are too long
105
packages/acl/dist-in/vfs/sanitizers.d.ts
vendored
Normal file
105
packages/acl/dist-in/vfs/sanitizers.d.ts
vendored
Normal 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;
|
||||
};
|
||||
163
packages/acl/dist-in/vfs/sanitizers.js
Normal file
163
packages/acl/dist-in/vfs/sanitizers.js
Normal 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 v1–v5 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
43
packages/acl/dist-in/vfs/vfs-acl.d.ts
vendored
Normal 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>;
|
||||
108
packages/acl/dist-in/vfs/vfs-acl.js
Normal file
108
packages/acl/dist-in/vfs/vfs-acl.js
Normal 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
256
packages/acl/docs/groups.md
Normal 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.
|
||||
45
packages/acl/eslint.config.js
Normal file
45
packages/acl/eslint.config.js
Normal 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',
|
||||
},
|
||||
},
|
||||
);
|
||||
1336
packages/acl/package-lock.json
generated
1336
packages/acl/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
176
packages/acl/src/vfs/sanitizers.ts
Normal file
176
packages/acl/src/vfs/sanitizers.ts
Normal 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 v1–v5 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;
|
||||
@ -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;
|
||||
|
||||
166
packages/acl/tests/vfs-acl-decorated.e2e.test.ts
Normal file
166
packages/acl/tests/vfs-acl-decorated.e2e.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
189
packages/acl/tests/vfs-acl-edge.e2e.test.ts
Normal file
189
packages/acl/tests/vfs-acl-edge.e2e.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
276
packages/acl/tests/vfs-acl-groups.e2e.test.ts
Normal file
276
packages/acl/tests/vfs-acl-groups.e2e.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
66
packages/acl/tests/vfs/root/edge-cases/vfs-settings.json
Normal file
66
packages/acl/tests/vfs/root/edge-cases/vfs-settings.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
56
packages/acl/tests/vfs/root/groups-test/vfs-settings.json
Normal file
56
packages/acl/tests/vfs/root/groups-test/vfs-settings.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -7,6 +7,11 @@
|
||||
"composite": false,
|
||||
"inlineSourceMap": true,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"allowUnusedLabels": false,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"allowUnreachableCode": false,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user