Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,106 @@
{
"name": "@suaveplan/permissions",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Full RBAC/ABAC permissions engine: Ability/AbilityBuilder, RoleManager, PolicyEngine, PermissionCache, conditions, and pluggable storage adapters",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/permission-error.d.ts",
"default": "./dist/errors/permission-error.js"
},
"require": {
"types": "./dist/errors/permission-error.d.ts",
"default": "./dist/errors/permission-error.js"
}
},
"./core/ability": {
"import": {
"types": "./dist/core/ability/ability.d.ts",
"default": "./dist/core/ability/ability.js"
},
"require": {
"types": "./dist/core/ability/ability.d.ts",
"default": "./dist/core/ability/ability.js"
}
},
"./core/role-manager": {
"import": {
"types": "./dist/core/role-manager/role-manager.d.ts",
"default": "./dist/core/role-manager/role-manager.js"
},
"require": {
"types": "./dist/core/role-manager/role-manager.d.ts",
"default": "./dist/core/role-manager/role-manager.js"
}
},
"./core/policy-engine": {
"import": {
"types": "./dist/core/policy-engine/policy-engine.d.ts",
"default": "./dist/core/policy-engine/policy-engine.js"
},
"require": {
"types": "./dist/core/policy-engine/policy-engine.d.ts",
"default": "./dist/core/policy-engine/policy-engine.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"bench": "bun run src/__benchmarks__/permissions.bench.ts",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/events": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,82 @@
# adapter-interface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `GenericPermissionAdapter` — the single storage contract for `@suaveplan/permissions`. By programming against this interface rather than a concrete implementation, the package remains fully provider-agnostic: swap from `MemoryAdapter` (development/tests) to a Redis, PostgreSQL, or SurrealDB backend (production) without touching any permissions logic.
The interface groups operations into three categories matching the domain model: permissions CRUD, roles CRUD, and user-role assignment management.
## Features
- Permission lifecycle: create, update, delete, find by ID, list with optional filter
- Role lifecycle: create, update, delete, find by ID, list with optional filter
- User-role assignment: assign, revoke, list user's roles, list role's users
- All methods are `async` to support both in-memory and remote backends
- Fully typed with `Permission`, `Role`, `UserRole` domain types
## Usage: Basic Example
```ts
import type { GenericPermissionAdapter } from "@suaveplan/permissions";
import { MemoryAdapter } from "@suaveplan/permissions";
const adapter: GenericPermissionAdapter = new MemoryAdapter();
const role = await adapter.createRole({
id: "r-1",
name: "Editor",
permissions: [],
});
```
## Usage: Advanced Example
```ts
import type { GenericPermissionAdapter, Permission, Role } from "@suaveplan/permissions";
class PostgresPermissionAdapter implements GenericPermissionAdapter {
constructor(private readonly pool: PgPool) {}
async createPermission(permission: Permission): Promise<Permission> {
await this.pool.query(
"INSERT INTO permissions (id, resource, action) VALUES ($1, $2, $3)",
[permission.id, permission.resource, permission.action],
);
return permission;
}
// ... remaining methods
}
```
## API Reference
### Permission methods
- `createPermission(permission): Promise<Permission>`
- `updatePermission(id, updates): Promise<Permission>`
- `deletePermission(id): Promise<void>`
- `findPermission(id): Promise<Permission | null>`
- `listPermissions(filter?): Promise<Permission[]>` — filter by `resource` and/or `action`
### Role methods
- `createRole(role): Promise<Role>`
- `updateRole(id, updates): Promise<Role>`
- `deleteRole(id): Promise<void>`
- `findRole(id): Promise<Role | null>`
- `listRoles(filter?): Promise<Role[]>` — filter by `siteId` and/or `isSystem`
### User-role assignment methods
- `assignRole(userRole): Promise<UserRole>`
- `revokeRole(userId, roleId, siteId?): Promise<void>`
- `getUserRoles(userId, siteId?): Promise<UserRole[]>`
- `getRoleUsers(roleId, siteId?): Promise<string[]>`
## Implementation Notes
The `siteId` parameter on several methods enables multi-tenant scenarios where a user has different roles in different sites/workspaces. Implementations that do not need multi-tenancy can ignore `siteId` (treat undefined and any value as the same bucket). The `updatePermission` and `updateRole` methods accept `Partial<T>` updates — implementations should merge these into the existing record rather than replacing it.

View File

@@ -0,0 +1,42 @@
import type {
Permission,
PermissionFilter,
Role,
RoleFilter,
UserRole,
} from "../../types/permissions.types.js";
/**
* Storage contract for permissions data.
*
* Implementations must support CRUD for permissions, roles, and user-role
* assignments. All methods are async to support both in-memory and remote
* backends.
*/
export interface GenericPermissionAdapter {
// ----- Permissions -----
createPermission(permission: Permission): Promise<Permission>;
updatePermission(
id: string,
updates: Partial<Permission>
): Promise<Permission>;
deletePermission(id: string): Promise<void>;
findPermission(id: string): Promise<Permission | null>;
listPermissions(filter?: PermissionFilter): Promise<Permission[]>;
// ----- Roles -----
createRole(role: Role): Promise<Role>;
updateRole(id: string, updates: Partial<Role>): Promise<Role>;
deleteRole(id: string): Promise<void>;
findRole(id: string): Promise<Role | null>;
listRoles(filter?: RoleFilter): Promise<Role[]>;
// ----- User-role assignments -----
assignRole(userRole: UserRole): Promise<UserRole>;
revokeRole(userId: string, roleId: string, siteId?: string): Promise<void>;
getUserRoles(userId: string, siteId?: string): Promise<UserRole[]>;
getRoleUsers(roleId: string, siteId?: string): Promise<string[]>;
}

View File

@@ -0,0 +1 @@
export type { GenericPermissionAdapter } from "./adapter-interface.js";

View File

@@ -0,0 +1 @@
export { MemoryAdapter } from "./memory.js";

View File

@@ -0,0 +1,128 @@
# memory-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MemoryAdapter` is the built-in in-memory implementation of `GenericPermissionAdapter`. It stores all permissions, roles, and user-role assignments in plain `Map` structures — no external dependencies, no I/O, deterministic behaviour. It ships for two purposes: as the reference implementation demonstrating how to satisfy the `GenericPermissionAdapter` contract, and as the default adapter for unit tests and single-process deployments.
The adapter uses a compound key `userId:roleId[:siteId]` for assignments, enabling multi-tenant role scoping without additional bookkeeping.
## Features
- Complete `GenericPermissionAdapter` implementation across permissions, roles, and assignments
- Compound assignment keys for optional multi-tenant `siteId` scoping
- `PermissionNotFoundError` / `RoleNotFoundError` / `PermissionsAdapterError` on invalid operations
- `protected` fields for straightforward subclassing in integration test fixtures
- `listPermissions` and `listRoles` support optional filter parameters
- Filter support: `listPermissions` by `resource`/`action`, `listRoles` by `siteId`/`isSystem`
## Usage: Basic Example
```ts
import { MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const perm = await adapter.createPermission({
id: "p-1",
resource: "posts",
action: "read",
});
const role = await adapter.createRole({
id: "r-viewer",
name: "Viewer",
permissions: [perm],
});
await adapter.assignRole({
id: "ur-1",
userId: "user-1",
roleId: role.id,
assignedAt: new Date(),
});
```
## Usage: Advanced Example
```ts
import { MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
// Multi-tenant role assignment
await adapter.assignRole({
id: "ur-site-a",
userId: "u1",
roleId: "admin",
siteId: "site-a",
assignedAt: new Date(),
});
const siteARoles = await adapter.getUserRoles("u1", "site-a");
console.log(siteARoles.length); // 1
const siteBRoles = await adapter.getUserRoles("u1", "site-b");
console.log(siteBRoles.length); // 0 — different site
```
## API Reference
### `createPermission(permission): Promise<Permission>`
Stores and returns the permission.
### `updatePermission(id, updates): Promise<Permission>`
Merges updates into the existing record. Throws `PermissionNotFoundError` if not found.
### `deletePermission(id): Promise<void>`
Removes the permission. Throws `PermissionNotFoundError` if not found.
### `findPermission(id): Promise<Permission | null>`
O(1) lookup by ID.
### `listPermissions(filter?): Promise<Permission[]>`
Returns all permissions, optionally filtered by `resource` and/or `action`.
### `createRole(role): Promise<Role>`
Stores and returns the role.
### `updateRole(id, updates): Promise<Role>`
Merges updates. Throws `RoleNotFoundError` if not found.
### `deleteRole(id): Promise<void>`
Removes the role. Throws `RoleNotFoundError` if not found.
### `findRole(id): Promise<Role | null>`
O(1) lookup.
### `listRoles(filter?): Promise<Role[]>`
Returns all roles, optionally filtered by `siteId` and/or `isSystem`.
### `assignRole(userRole): Promise<UserRole>`
Stores the assignment using a compound key.
### `revokeRole(userId, roleId, siteId?): Promise<void>`
Removes the assignment. Throws `PermissionsAdapterError` if not found.
### `getUserRoles(userId, siteId?): Promise<UserRole[]>`
Returns all assignments for the user, optionally scoped to a site.
### `getRoleUsers(roleId, siteId?): Promise<string[]>`
Returns user IDs assigned to a role.
## Implementation Notes
The assignment key `userId:roleId[:siteId]` ensures that the same user/role pair is only stored once per site scope, preventing duplicate assignments. For production use with thousands of roles or users, a database adapter with proper indices is strongly recommended.

View File

@@ -0,0 +1,279 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PermissionNotFoundError,
RoleNotFoundError,
} from "../../errors/permission-error.js";
import type {
Permission,
Role,
UserRole,
} from "../../types/permissions.types.js";
import { MemoryAdapter } from "./memory.js";
function makePerm(overrides: Partial<Permission> = {}): Permission {
return {
id: "p-1",
resource: "posts",
action: "read",
...overrides,
};
}
function makeRole(overrides: Partial<Role> = {}): Role {
return {
id: "r-1",
name: "Viewer",
permissions: [],
...overrides,
};
}
function makeUserRole(overrides: Partial<UserRole> = {}): UserRole {
return {
id: "ur-1",
userId: "u-1",
roleId: "r-1",
assignedAt: new Date(),
...overrides,
};
}
describe("MemoryAdapter — Permissions", () => {
it("creates and retrieves a permission", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const p = await adapter.createPermission(makePerm());
expect(await adapter.findPermission("p-1")).toEqual(p);
});
it("returns null for unknown permission", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
expect(await adapter.findPermission("nope")).toBeNull();
});
it("updates a permission", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createPermission(makePerm());
const updated = await adapter.updatePermission("p-1", {
action: "write",
});
expect(updated.action).toBe("write");
});
it("throws on update of unknown permission", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
await expect(
adapter.updatePermission("nope", { action: "read" })
).rejects.toBeInstanceOf(PermissionNotFoundError);
});
it("deletes a permission", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createPermission(makePerm());
await adapter.deletePermission("p-1");
expect(await adapter.findPermission("p-1")).toBeNull();
});
it("throws on delete of unknown permission", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
await expect(adapter.deletePermission("nope")).rejects.toBeInstanceOf(
PermissionNotFoundError
);
});
it("lists permissions with resource filter", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
await adapter.createPermission(
makePerm({ id: "p1", resource: "posts" })
);
await adapter.createPermission(
makePerm({ id: "p2", resource: "comments" })
);
const results = await adapter.listPermissions({ resource: "posts" });
expect(results).toHaveLength(1);
expect(results[0]?.resource).toBe("posts");
});
it("lists all permissions without filter", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createPermission(makePerm({ id: "p1" }));
await adapter.createPermission(makePerm({ id: "p2" }));
expect(await adapter.listPermissions()).toHaveLength(2);
});
it("lists permissions with action filter", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createPermission(makePerm({ id: "p1", action: "read" }));
await adapter.createPermission(makePerm({ id: "p2", action: "write" }));
const results = await adapter.listPermissions({ action: "read" });
expect(results).toHaveLength(1);
expect(results[0]?.action).toBe("read");
});
});
describe("MemoryAdapter — Roles", () => {
it("creates and retrieves a role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const r = await adapter.createRole(makeRole());
expect(await adapter.findRole("r-1")).toEqual(r);
});
it("returns null for unknown role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
expect(await adapter.findRole("nope")).toBeNull();
});
it("updates a role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole(makeRole());
const updated = await adapter.updateRole("r-1", {
name: "Super Viewer",
});
expect(updated.name).toBe("Super Viewer");
});
it("throws on update of unknown role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await expect(
adapter.updateRole("nope", { name: "x" })
).rejects.toBeInstanceOf(RoleNotFoundError);
});
it("deletes a role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole(makeRole());
await adapter.deleteRole("r-1");
expect(await adapter.findRole("r-1")).toBeNull();
});
it("throws on delete of unknown role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await expect(adapter.deleteRole("nope")).rejects.toBeInstanceOf(
RoleNotFoundError
);
});
it("lists roles with siteId filter", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole(makeRole({ id: "r1", siteId: "site-a" }));
await adapter.createRole(makeRole({ id: "r2", siteId: "site-b" }));
const results = await adapter.listRoles({ siteId: "site-a" });
expect(results).toHaveLength(1);
});
it("lists system roles", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole(makeRole({ id: "r1", isSystem: true }));
await adapter.createRole(makeRole({ id: "r2", isSystem: false }));
const sys = await adapter.listRoles({ isSystem: true });
expect(sys).toHaveLength(1);
});
});
describe("MemoryAdapter — Assignments", () => {
it("assigns a role to a user", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const ur = await adapter.assignRole(makeUserRole());
expect(ur.userId).toBe("u-1");
});
it("gets user roles", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.assignRole(makeUserRole({ id: "ur1", roleId: "r1" }));
await adapter.assignRole(makeUserRole({ id: "ur2", roleId: "r2" }));
const roles = await adapter.getUserRoles("u-1");
expect(roles).toHaveLength(2);
});
it("scopes user roles by siteId", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.assignRole(
makeUserRole({ id: "ur1", roleId: "r1", siteId: "s-a" })
);
await adapter.assignRole(
makeUserRole({ id: "ur2", roleId: "r2", siteId: "s-b" })
);
const siteA = await adapter.getUserRoles("u-1", "s-a");
expect(siteA).toHaveLength(1);
});
it("gets role users", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.assignRole(
makeUserRole({ id: "ur1", userId: "u1", roleId: "r-1" })
);
await adapter.assignRole(
makeUserRole({ id: "ur2", userId: "u2", roleId: "r-1" })
);
const users = await adapter.getRoleUsers("r-1");
expect(users).toHaveLength(2);
});
it("revokes a role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.assignRole(makeUserRole());
await adapter.revokeRole("u-1", "r-1");
expect(await adapter.getUserRoles("u-1")).toHaveLength(0);
});
it("throws on revoke of missing assignment", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
await expect(adapter.revokeRole("u-1", "r-1")).rejects.toBeInstanceOf(
Error
);
});
it("returns empty arrays for unknown user/role", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
expect(await adapter.getUserRoles("nobody")).toEqual([]);
expect(await adapter.getRoleUsers("norole")).toEqual([]);
});
it("scopes role users by siteId", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.assignRole(
makeUserRole({
id: "ur1",
userId: "u1",
roleId: "r-1",
siteId: "s-a",
})
);
await adapter.assignRole(
makeUserRole({
id: "ur2",
userId: "u2",
roleId: "r-1",
siteId: "s-b",
})
);
const usersInSiteA = await adapter.getRoleUsers("r-1", "s-a");
expect(usersInSiteA).toHaveLength(1);
expect(usersInSiteA[0]).toBe("u1");
});
it("getRoleUsers skips entries with different roleId", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
await adapter.assignRole(
makeUserRole({ id: "ur1", userId: "u1", roleId: "r-1" })
);
await adapter.assignRole(
makeUserRole({ id: "ur2", userId: "u2", roleId: "r-2" })
);
// Only r-1's users — r-2's user should be skipped
const users = await adapter.getRoleUsers("r-1");
expect(users).toHaveLength(1);
expect(users[0]).toBe("u1");
});
});

View File

@@ -0,0 +1,158 @@
import {
PermissionNotFoundError,
PermissionsAdapterError,
RoleNotFoundError,
} from "../../errors/permission-error.js";
import type {
Permission,
PermissionFilter,
Role,
RoleFilter,
UserRole,
} from "../../types/permissions.types.js";
import type { GenericPermissionAdapter } from "../adapter-interface/adapter-interface.js";
/**
* In-memory implementation of `GenericPermissionAdapter`.
*
* Stores all data in Maps. Suitable for tests and single-process deployments.
* Does not persist across restarts or support multi-process concurrency.
*/
export class MemoryAdapter implements GenericPermissionAdapter {
protected readonly permissions: Map<string, Permission> = new Map();
protected readonly roles: Map<string, Role> = new Map();
protected readonly userRoles: Map<string, UserRole> = new Map();
// ----- Permissions -----
async createPermission(permission: Permission): Promise<Permission> {
this.permissions.set(permission.id, permission);
return permission;
}
async updatePermission(
id: string,
updates: Partial<Permission>
): Promise<Permission> {
const existing = this.permissions.get(id);
if (!existing) throw new PermissionNotFoundError(id);
const updated: Permission = { ...existing, ...updates, id };
this.permissions.set(id, updated);
return updated;
}
async deletePermission(id: string): Promise<void> {
if (!this.permissions.has(id)) throw new PermissionNotFoundError(id);
this.permissions.delete(id);
}
async findPermission(id: string): Promise<Permission | null> {
return this.permissions.get(id) ?? null;
}
async listPermissions(filter?: PermissionFilter): Promise<Permission[]> {
let results = Array.from(this.permissions.values());
if (filter?.resource !== undefined) {
results = results.filter((p) => p.resource === filter.resource);
}
if (filter?.action !== undefined) {
results = results.filter((p) => p.action === filter.action);
}
return results;
}
// ----- Roles -----
async createRole(role: Role): Promise<Role> {
this.roles.set(role.id, role);
return role;
}
async updateRole(id: string, updates: Partial<Role>): Promise<Role> {
const existing = this.roles.get(id);
if (!existing) throw new RoleNotFoundError(id);
const updated: Role = { ...existing, ...updates, id };
this.roles.set(id, updated);
return updated;
}
async deleteRole(id: string): Promise<void> {
if (!this.roles.has(id)) throw new RoleNotFoundError(id);
this.roles.delete(id);
}
async findRole(id: string): Promise<Role | null> {
return this.roles.get(id) ?? null;
}
async listRoles(filter?: RoleFilter): Promise<Role[]> {
let results = Array.from(this.roles.values());
if (filter?.siteId !== undefined) {
results = results.filter((r) => r.siteId === filter.siteId);
}
if (filter?.isSystem !== undefined) {
results = results.filter(
(r) =>
(r.isSystem ?? /* istanbul ignore next */ false) ===
filter.isSystem
);
}
return results;
}
// ----- User-role assignments -----
async assignRole(userRole: UserRole): Promise<UserRole> {
const key = this._assignmentKey(
userRole.userId,
userRole.roleId,
userRole.siteId
);
this.userRoles.set(key, userRole);
return userRole;
}
async revokeRole(
userId: string,
roleId: string,
siteId?: string
): Promise<void> {
const key = this._assignmentKey(userId, roleId, siteId);
if (!this.userRoles.has(key)) {
throw new PermissionsAdapterError({
message: `No assignment for user "${userId}" role "${roleId}"`,
});
}
this.userRoles.delete(key);
}
async getUserRoles(userId: string, siteId?: string): Promise<UserRole[]> {
const results: UserRole[] = [];
for (const ur of this.userRoles.values()) {
if (ur.userId !== userId) continue;
if (siteId !== undefined && ur.siteId !== siteId) continue;
results.push(ur);
}
return results;
}
async getRoleUsers(roleId: string, siteId?: string): Promise<string[]> {
const results: string[] = [];
for (const ur of this.userRoles.values()) {
if (ur.roleId !== roleId) continue;
if (siteId !== undefined && ur.siteId !== siteId) continue;
results.push(ur.userId);
}
return results;
}
protected _assignmentKey(
userId: string,
roleId: string,
siteId?: string
): string {
return siteId !== undefined
? `${userId}:${roleId}:${siteId}`
: `${userId}:${roleId}`;
}
}

View File

@@ -0,0 +1,4 @@
export {
PermissionCache,
type PermissionCacheOptions,
} from "./permission-cache.js";

View File

@@ -0,0 +1,92 @@
# permission-cache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PermissionCache` provides a TTL-bounded, size-capped in-process cache for permission check results in `@suaveplan/permissions`. Permission checks can be expensive — they may traverse multi-level role inheritance chains and evaluate ABAC conditions — so caching their boolean outcome for frequently-checked user/resource/action triples significantly reduces latency on hot paths.
The cache accepts an injectable `Clock` for deterministic testing: advance the fake clock to simulate TTL expiry without wall-clock waits.
## Features
- TTL-based expiry: entries older than `ttl` ms are evicted on read
- Size-capped: when full, the oldest entry is evicted before inserting a new one
- `invalidate(userId)` — invalidates all entries for a specific user by key prefix
- `invalidateAll()` — clears the entire cache (e.g. on role assignment change)
- `size` getter — returns the current entry count (including not-yet-evicted expired entries)
- Injectable `Clock` for deterministic test control
## Usage: Basic Example
```ts
import { PermissionCache } from "@suaveplan/permissions";
const cache = new PermissionCache({ ttl: 60_000 });
cache.set("u1:posts:read", true);
console.log(cache.get("u1:posts:read")); // true
cache.invalidate("u1");
console.log(cache.get("u1:posts:read")); // undefined — invalidated
```
## Usage: Advanced Example
```ts
import { PermissionCache } from "@suaveplan/permissions";
let tick = 0;
const cache = new PermissionCache({ ttl: 1000, clock: () => tick });
cache.set("u1:posts:read", true);
tick = 999;
console.log(cache.get("u1:posts:read")); // true — not yet expired
tick = 1001;
console.log(cache.get("u1:posts:read")); // undefined — expired
// With PolicyEngine
import { PolicyEngine, RoleManager } from "@suaveplan/permissions";
import { MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const roleManager = new RoleManager(adapter);
const policyEngine = new PolicyEngine(
new PermissionManager(adapter),
{ cache: new PermissionCache({ ttl: 30_000 }) },
);
```
## API Reference
### `constructor(options: PermissionCacheOptions)`
- `options.ttl` — entry TTL in milliseconds (required)
- `options.maxSize` — maximum entry count before eviction (default: 1 000)
- `options.clock` — injectable time source (default: `Date.now`)
### `get(key: string): boolean | undefined`
Returns the cached value if present and not expired, or `undefined`. Auto-evicts expired entries on read.
### `set(key: string, value: boolean): void`
Stores an entry. If the cache is at capacity, evicts the oldest entry first.
### `invalidate(userId: string): void`
Removes all entries whose key starts with `userId:`. Use after role assignment changes.
### `invalidateAll(): void`
Clears all entries.
### `size: number`
Current entry count. May include entries that are expired but not yet read (lazy eviction).
## Implementation Notes
The eviction strategy is "evict the oldest-inserted entry" (Map insertion order) rather than true LRU. This keeps the implementation O(1) for all operations without the overhead of a doubly-linked list. For most permission-check workloads — where the hot users are also the most recently active — insertion-order eviction provides good cache efficiency. The `invalidate(userId)` method iterates all keys but the cache is bounded by `maxSize`, so the worst case is O(maxSize) — acceptable for typical values (≤10 000).

View File

@@ -0,0 +1,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { PermissionCache } from "./permission-cache.js";
describe("PermissionCache", () => {
it("stores and retrieves a value", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 10000 });
cache.set("u1:posts:read", true);
expect(cache.get("u1:posts:read")).toBe(true);
});
it("returns undefined for missing key", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 10000 });
expect(cache.get("missing")).toBeUndefined();
});
it("returns false value correctly", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 10000 });
cache.set("u1:posts:delete", false);
expect(cache.get("u1:posts:delete")).toBe(false);
});
it("evicts expired entries on read", { timeout: 5000 }, () => {
let tick = 0;
const cache = new PermissionCache({ ttl: 100, clock: () => tick });
cache.set("k", true);
tick = 101;
expect(cache.get("k")).toBeUndefined();
expect(cache.size).toBe(0);
});
it("does not evict non-expired entries", { timeout: 5000 }, () => {
let tick = 0;
const cache = new PermissionCache({ ttl: 1000, clock: () => tick });
cache.set("k", true);
tick = 500;
expect(cache.get("k")).toBe(true);
});
it("evicts oldest when full", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 99999, maxSize: 2 });
cache.set("a", true);
cache.set("b", false);
cache.set("c", true); // should evict "a"
expect(cache.get("a")).toBeUndefined();
expect(cache.get("b")).toBe(false);
expect(cache.get("c")).toBe(true);
});
it("does not evict when updating existing key", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 99999, maxSize: 2 });
cache.set("a", true);
cache.set("b", false);
cache.set("a", false); // update, not insert
expect(cache.size).toBe(2);
expect(cache.get("a")).toBe(false);
});
it("invalidates by userId prefix", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 99999 });
cache.set("u1:posts:read", true);
cache.set("u1:posts:write", false);
cache.set("u2:posts:read", true);
cache.invalidate("u1");
expect(cache.get("u1:posts:read")).toBeUndefined();
expect(cache.get("u1:posts:write")).toBeUndefined();
expect(cache.get("u2:posts:read")).toBe(true);
});
it("invalidateAll clears all entries", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 99999 });
cache.set("a", true);
cache.set("b", true);
cache.invalidateAll();
expect(cache.size).toBe(0);
});
it("size reflects stored entries", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 99999 });
expect(cache.size).toBe(0);
cache.set("a", true);
expect(cache.size).toBe(1);
cache.set("b", false);
expect(cache.size).toBe(2);
});
it("cache completeness after invalidation", { timeout: 5000 }, () => {
const cache = new PermissionCache({ ttl: 99999 });
cache.set("u1:posts:read", true);
cache.set("u1:comments:write", false);
cache.invalidate("u1");
expect(cache.get("u1:posts:read")).toBeUndefined();
expect(cache.get("u1:comments:write")).toBeUndefined();
expect(cache.size).toBe(0);
});
});

View File

@@ -0,0 +1,79 @@
import type { Clock } from "@suaveplan/types";
export interface PermissionCacheOptions {
/** Entry TTL in milliseconds. */
readonly ttl: number;
/** Maximum number of entries (default: 1 000). */
readonly maxSize?: number;
readonly clock?: Clock;
}
interface CacheEntry {
value: boolean;
expiresAt: number;
}
/**
* LRU-inspired TTL cache for permission check results.
*
* Stores `boolean` results keyed by a composite string (typically
* `${userId}:${resource}:${action}`). Supports per-user and global
* invalidation. Injectable clock for deterministic tests.
*/
export class PermissionCache {
protected readonly store: Map<string, CacheEntry> = new Map();
protected readonly clock: Clock;
protected readonly ttl: number;
protected readonly maxSize: number;
constructor(options: PermissionCacheOptions) {
this.ttl = options.ttl;
this.maxSize = options.maxSize ?? 1_000;
this.clock = options.clock ?? (() => Date.now());
}
/** Retrieve a cached result, or `undefined` if absent or expired. */
get(key: string): boolean | undefined {
const entry = this.store.get(key);
if (entry === undefined) return undefined;
if (entry.expiresAt <= this.clock()) {
this.store.delete(key);
return undefined;
}
return entry.value;
}
/** Store a result with TTL. Evicts the oldest entry when full. */
set(key: string, value: boolean): void {
if (this.store.size >= this.maxSize && !this.store.has(key)) {
// Evict oldest entry
const firstKey = this.store.keys().next().value;
// Map is non-empty (size >= maxSize > 0) so firstKey is always defined
this.store.delete(firstKey as string);
}
this.store.set(key, {
value,
expiresAt: this.clock() + this.ttl,
});
}
/** Invalidate all entries whose key starts with `userId:`. */
invalidate(userId: string): void {
const prefix = `${userId}:`;
for (const key of this.store.keys()) {
if (key.startsWith(prefix)) {
this.store.delete(key);
}
}
}
/** Remove all entries from the cache. */
invalidateAll(): void {
this.store.clear();
}
/** Current number of entries (including expired). */
get size(): number {
return this.store.size;
}
}

View File

@@ -0,0 +1,70 @@
# condition-matcher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the condition evaluation engine for ABAC (Attribute-Based Access Control) in `@suaveplan/permissions`. `DefaultConditionMatcher` evaluates `PermissionCondition` objects against a request context, enabling fine-grained access control rules like "user can read posts only if post.authorId === userId".
The `compareValues` function is the pure functional core — it handles all 12 supported operators and is used directly by both `DefaultConditionMatcher` and the `Ability` class for rule evaluation.
## Features
- `compareValues(a, b, operator)` — pure comparison function supporting all 12 `ConditionOperator` values
- `DefaultConditionMatcher` implementing `ConditionMatcher` interface
- Dot-notation field resolution from context objects (e.g. `"post.author.id"`)
- Safe `RegExp` evaluation for `matches` operator (catches invalid patterns)
- `ConditionMatcher` interface for custom implementations
## Usage: Basic Example
```ts
import { DefaultConditionMatcher, compareValues } from "@suaveplan/permissions";
const matcher = new DefaultConditionMatcher();
const result = matcher.matches(
{ field: "user.role", operator: "eq", value: "admin" },
{ user: { role: "admin" } },
);
console.log(result); // true
```
## Usage: Advanced Example
```ts
import { compareValues } from "@suaveplan/permissions";
// Numeric comparison
compareValues(5, 3, "gt"); // true
compareValues(2, 5, "lt"); // true
// Array membership
compareValues("admin", ["admin", "editor"], "in"); // true
compareValues("viewer", ["admin", "editor"], "nin"); // true
// String operations
compareValues("hello world", "hello", "startsWith"); // true
compareValues("test@example.com", "@example", "contains"); // true
compareValues("abc123", "^[a-z]+\\d+$", "matches"); // true
```
## API Reference
### `compareValues(a, b, operator): boolean`
Pure comparison. Both `a` (actual) and `b` (expected) are `unknown`. Returns `false` for type mismatches.
Operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `startsWith`, `endsWith`, `matches`.
### `ConditionMatcher`
Interface: `matches(condition: PermissionCondition, context: Record<string, unknown>): boolean`.
### `DefaultConditionMatcher`
Implements `ConditionMatcher`. Resolves `condition.field` from `context` using dot notation before calling `compareValues`.
## Implementation Notes
Field resolution uses `.split(".")` iteration rather than `eval` or `Function()` — it is safe from injection attacks and works with any serialisable context object. The `matches` operator wraps `RegExp` construction in a `try/catch` to prevent invalid patterns from throwing at evaluation time. Numeric comparison operators (`gt`, `gte`, `lt`, `lte`) return `false` if either operand is not a `number`, rather than attempting type coercion, to prevent surprising behaviour with string numbers.

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { compareValues, DefaultConditionMatcher } from "./condition-matcher.js";
describe("compareValues", () => {
it("eq — equal values", { timeout: 5000 }, () => {
expect(compareValues("a", "a", "eq")).toBe(true);
expect(compareValues("a", "b", "eq")).toBe(false);
});
it("neq — not equal", { timeout: 5000 }, () => {
expect(compareValues("a", "b", "neq")).toBe(true);
expect(compareValues("a", "a", "neq")).toBe(false);
});
it("gt / gte", { timeout: 5000 }, () => {
expect(compareValues(5, 3, "gt")).toBe(true);
expect(compareValues(3, 5, "gt")).toBe(false);
expect(compareValues(5, 5, "gte")).toBe(true);
expect(compareValues(4, 5, "gte")).toBe(false);
});
it("lt / lte", { timeout: 5000 }, () => {
expect(compareValues(1, 5, "lt")).toBe(true);
expect(compareValues(5, 1, "lt")).toBe(false);
expect(compareValues(5, 5, "lte")).toBe(true);
expect(compareValues(6, 5, "lte")).toBe(false);
});
it("gt/lt return false for non-numbers", { timeout: 5000 }, () => {
expect(compareValues("a", "b", "gt")).toBe(false);
expect(compareValues("a", "b", "lt")).toBe(false);
});
it("in — value in array", { timeout: 5000 }, () => {
expect(compareValues("admin", ["admin", "editor"], "in")).toBe(true);
expect(compareValues("viewer", ["admin", "editor"], "in")).toBe(false);
});
it("nin — value not in array", { timeout: 5000 }, () => {
expect(compareValues("viewer", ["admin", "editor"], "nin")).toBe(true);
expect(compareValues("admin", ["admin", "editor"], "nin")).toBe(false);
});
it("in returns false for non-array b", { timeout: 5000 }, () => {
expect(compareValues("a", "a", "in")).toBe(false);
});
it("contains — string contains string", { timeout: 5000 }, () => {
expect(compareValues("hello world", "world", "contains")).toBe(true);
expect(compareValues("hello world", "xyz", "contains")).toBe(false);
});
it("contains — array contains value", { timeout: 5000 }, () => {
expect(compareValues([1, 2, 3], 2, "contains")).toBe(true);
expect(compareValues([1, 2, 3], 4, "contains")).toBe(false);
});
it("contains returns false for non-string/array", { timeout: 5000 }, () => {
expect(compareValues(42, "4", "contains")).toBe(false);
});
it("startsWith", { timeout: 5000 }, () => {
expect(compareValues("hello", "hell", "startsWith")).toBe(true);
expect(compareValues("hello", "world", "startsWith")).toBe(false);
});
it("endsWith", { timeout: 5000 }, () => {
expect(compareValues("hello", "llo", "endsWith")).toBe(true);
expect(compareValues("hello", "hel", "endsWith")).toBe(false);
});
it("matches — regex match", { timeout: 5000 }, () => {
expect(compareValues("abc123", "^[a-z]+\\d+$", "matches")).toBe(true);
expect(compareValues("123abc", "^[a-z]+\\d+$", "matches")).toBe(false);
});
it("matches — invalid regex returns false", { timeout: 5000 }, () => {
expect(compareValues("abc", "[invalid(regex", "matches")).toBe(false);
});
it("matches returns false for non-strings", { timeout: 5000 }, () => {
expect(compareValues(42, "\\d+", "matches")).toBe(false);
});
});
describe("DefaultConditionMatcher", () => {
const matcher = new DefaultConditionMatcher();
it("matches flat field", { timeout: 5000 }, () => {
expect(
matcher.matches(
{ field: "role", operator: "eq", value: "admin" },
{ role: "admin" }
)
).toBe(true);
});
it("resolves dot-notation field", { timeout: 5000 }, () => {
expect(
matcher.matches(
{ field: "user.role", operator: "eq", value: "editor" },
{ user: { role: "editor" } }
)
).toBe(true);
});
it("returns false for missing field", { timeout: 5000 }, () => {
expect(
matcher.matches(
{ field: "missing.field", operator: "eq", value: "x" },
{}
)
).toBe(false);
});
it("resolves deeply nested field", { timeout: 5000 }, () => {
expect(
matcher.matches(
{ field: "a.b.c", operator: "eq", value: 42 },
{ a: { b: { c: 42 } } }
)
).toBe(true);
});
it("handles null intermediate value", { timeout: 5000 }, () => {
expect(
matcher.matches(
{ field: "a.b", operator: "eq", value: "x" },
{ a: null }
)
).toBe(false);
});
it("handles non-object intermediate value (returns false)", {
timeout: 5000,
}, () => {
// 'a' is a string (non-object), so 'a.b' cannot be traversed
expect(
matcher.matches(
{ field: "a.b", operator: "eq", value: "x" },
{ a: "string-not-object" }
)
).toBe(false);
});
});

View File

@@ -0,0 +1,94 @@
import type {
ConditionOperator,
PermissionCondition,
} from "../../types/permissions.types.js";
/**
* Compare two values using the given operator.
*
* Supports eq, neq, gt, gte, lt, lte, in, nin, contains, startsWith, endsWith, matches.
*/
export function compareValues(
a: unknown,
b: unknown,
operator: ConditionOperator
): boolean {
switch (operator) {
case "eq":
return a === b;
case "neq":
return a !== b;
case "gt":
return typeof a === "number" && typeof b === "number" && a > b;
case "gte":
return typeof a === "number" && typeof b === "number" && a >= b;
case "lt":
return typeof a === "number" && typeof b === "number" && a < b;
case "lte":
return typeof a === "number" && typeof b === "number" && a <= b;
case "in":
return Array.isArray(b) && b.includes(a);
case "nin":
return Array.isArray(b) && !b.includes(a);
case "contains":
if (typeof a === "string" && typeof b === "string")
return a.includes(b);
if (Array.isArray(a)) return a.includes(b);
return false;
case "startsWith":
return (
typeof a === "string" &&
typeof b === "string" &&
a.startsWith(b)
);
case "endsWith":
return (
typeof a === "string" && typeof b === "string" && a.endsWith(b)
);
case "matches":
if (typeof a === "string" && typeof b === "string") {
try {
return new RegExp(b).test(a);
} catch {
return false;
}
}
return false;
}
}
/** Contract for a condition evaluator. */
export interface ConditionMatcher {
matches(
condition: PermissionCondition,
context: Record<string, unknown>
): boolean;
}
/**
* Default condition matcher.
*
* Reads the field value from the context using dot-notation and applies
* `compareValues` with the condition operator.
*/
export class DefaultConditionMatcher implements ConditionMatcher {
matches(
condition: PermissionCondition,
context: Record<string, unknown>
): boolean {
const fieldValue = getNestedValue(context, condition.field);
return compareValues(fieldValue, condition.value, condition.operator);
}
}
/** Read a nested value from an object using dot notation. */
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split(".");
let current: unknown = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}

View File

@@ -0,0 +1,5 @@
export {
type ConditionMatcher,
compareValues,
DefaultConditionMatcher,
} from "./condition-matcher.js";

View File

@@ -0,0 +1,64 @@
# field-matcher
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `FieldMatcher` interface and its default implementation for resolving field values from request context objects in `@suaveplan/permissions`. Separating field resolution from condition evaluation allows custom implementations to support advanced context shapes such as flat maps, typed records, or proxy objects that intercept property access.
The `fieldMatches` convenience function combines field resolution with condition evaluation in a single call, making it easy to check individual conditions without instantiating a full `ConditionMatcher`.
## Features
- `FieldMatcher` interface with a single `get(context, field): unknown` method
- `DefaultFieldMatcher` implementing dot-notation path resolution
- `fieldMatches(context, condition, matcher?)` convenience combinator
- Handles `null` and `undefined` intermediate values gracefully
## Usage: Basic Example
```ts
import { DefaultFieldMatcher, fieldMatches } from "@suaveplan/permissions";
const context = { post: { authorId: "u1" } };
const condition = { field: "post.authorId", operator: "eq", value: "u1" };
console.log(fieldMatches(context, condition)); // true
```
## Usage: Advanced Example
```ts
import type { FieldMatcher } from "@suaveplan/permissions";
class PrefixedFieldMatcher implements FieldMatcher {
constructor(private readonly prefix: string) {}
get(context: Record<string, unknown>, field: string): unknown {
return context[`${this.prefix}.${field}`];
}
}
const matcher = new PrefixedFieldMatcher("request");
const context = { "request.userId": "u42" };
const val = matcher.get(context, "userId");
console.log(val); // "u42"
```
## API Reference
### `FieldMatcher`
Interface: `get(context: Record<string, unknown>, field: string): unknown`.
### `DefaultFieldMatcher`
Implements `FieldMatcher`. Resolves dot-notation paths (e.g. `"a.b.c"`) by iterating path segments. Returns `undefined` if any intermediate value is `null`, `undefined`, or non-object.
### `fieldMatches(context, condition, matcher?): boolean`
Resolves `condition.field` from `context` using `matcher` (default: `DefaultFieldMatcher`), then calls `compareValues`. Returns `false` if the field is not found.
## Implementation Notes
The `DefaultFieldMatcher.get` method uses the same safe dot-notation traversal as `DefaultConditionMatcher` — both iterate segments without `eval`. The `fieldMatches` function creates a new `DefaultFieldMatcher` instance if none is provided; for hot paths where this function is called millions of times, pass a pre-constructed matcher to avoid the per-call allocation.

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DefaultFieldMatcher, fieldMatches } from "./field-matcher.js";
describe("DefaultFieldMatcher", () => {
const fm = new DefaultFieldMatcher();
it("resolves flat key", { timeout: 5000 }, () => {
expect(fm.get({ a: 1 }, "a")).toBe(1);
});
it("resolves dot-notation", { timeout: 5000 }, () => {
expect(fm.get({ x: { y: { z: 42 } } }, "x.y.z")).toBe(42);
});
it("returns undefined for missing key", { timeout: 5000 }, () => {
expect(fm.get({ a: 1 }, "b")).toBeUndefined();
});
it("returns undefined for null intermediate", { timeout: 5000 }, () => {
expect(fm.get({ a: null }, "a.b")).toBeUndefined();
});
it("returns undefined for non-object intermediate", {
timeout: 5000,
}, () => {
expect(fm.get({ a: 42 }, "a.b")).toBeUndefined();
});
});
describe("fieldMatches", () => {
it("returns true when field matches condition", { timeout: 5000 }, () => {
expect(
fieldMatches(
{ user: { id: "u1" } },
{ field: "user.id", operator: "eq", value: "u1" }
)
).toBe(true);
});
it("returns false when field does not match", { timeout: 5000 }, () => {
expect(
fieldMatches(
{ user: { id: "u2" } },
{ field: "user.id", operator: "eq", value: "u1" }
)
).toBe(false);
});
it("accepts custom matcher", { timeout: 5000 }, () => {
const custom: import("./field-matcher.js").FieldMatcher = {
get: (_ctx, field) => (field === "score" ? 99 : undefined),
};
expect(
fieldMatches(
{},
{ field: "score", operator: "gt", value: 50 },
custom
)
).toBe(true);
});
});

View File

@@ -0,0 +1,35 @@
import type { PermissionCondition } from "../../types/permissions.types.js";
import { compareValues } from "../condition-matcher/condition-matcher.js";
/** Contract for extracting a field value from a context object. */
export interface FieldMatcher {
get(context: Record<string, unknown>, field: string): unknown;
}
/**
* Default field matcher — resolves dot-notation paths from a flat context.
*/
export class DefaultFieldMatcher implements FieldMatcher {
get(context: Record<string, unknown>, field: string): unknown {
const parts = field.split(".");
let current: unknown = context;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
if (typeof current !== "object") return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
}
/**
* Convenience function: resolve a field from a context and apply a condition.
*/
export function fieldMatches(
context: Record<string, unknown>,
condition: PermissionCondition,
matcher: FieldMatcher = new DefaultFieldMatcher()
): boolean {
const value = matcher.get(context, condition.field);
return compareValues(value, condition.value, condition.operator);
}

View File

@@ -0,0 +1,5 @@
export {
DefaultFieldMatcher,
type FieldMatcher,
fieldMatches,
} from "./field-matcher.js";

View File

@@ -0,0 +1,4 @@
export {
DefaultSubjectDetector,
type SubjectDetector,
} from "./subject-detector.js";

View File

@@ -0,0 +1,60 @@
# subject-detector
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `SubjectDetector` interface and its default implementation for extracting a resource type string from an arbitrary object in `@suaveplan/permissions`. Subject detection enables the `Ability` system to determine which permission rules apply to a given resource instance at runtime, without requiring the caller to manually pass a resource type string on every check.
The default implementation looks for well-known properties (`__typename`, `type`, `resourceType`) that are commonly present in GraphQL responses, domain objects, and REST API payloads.
## Features
- `SubjectDetector` interface with a single `detect(obj): string | undefined` method
- `DefaultSubjectDetector` checking `__typename`, `type`, and `resourceType` properties
- Safe: handles `null`, `undefined`, and non-object values without throwing
- Returns `undefined` (not throws) when no subject type can be determined
## Usage: Basic Example
```ts
import { DefaultSubjectDetector } from "@suaveplan/permissions";
const detector = new DefaultSubjectDetector();
const post = { __typename: "Post", id: "1", title: "Hello" };
console.log(detector.detect(post)); // "Post"
const untyped = { id: "1", value: 42 };
console.log(detector.detect(untyped)); // undefined
```
## Usage: Advanced Example
```ts
import type { SubjectDetector } from "@suaveplan/permissions";
class DomainSubjectDetector implements SubjectDetector {
detect(obj: unknown): string | undefined {
if (typeof obj !== "object" || obj === null) return undefined;
const r = obj as Record<string, unknown>;
// Domain objects use a Symbol-keyed type tag
return typeof r["entityType"] === "string" ? r["entityType"] : undefined;
}
}
```
## API Reference
### `SubjectDetector`
Interface: `detect(obj: unknown): string | undefined`.
### `DefaultSubjectDetector`
Inspects `obj.__typename`, `obj.type`, and `obj.resourceType` in that order, returning the first non-empty string value found, or `undefined`.
## Implementation Notes
The property lookup order (`__typename` first) is deliberate: it prioritises GraphQL-style typing, which is the most common source of typed resource objects in modern APIs. The check is purely structural (duck-typing) — no class hierarchy or prototype inspection is performed, ensuring broad compatibility with plain objects, frozen records, and proxy-wrapped values.

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { DefaultSubjectDetector } from "./subject-detector.js";
const detector = new DefaultSubjectDetector();
describe("DefaultSubjectDetector", () => {
it("detects __typename", { timeout: 5000 }, () => {
expect(detector.detect({ __typename: "Post" })).toBe("Post");
});
it("detects type", { timeout: 5000 }, () => {
expect(detector.detect({ type: "Comment" })).toBe("Comment");
});
it("detects resourceType", { timeout: 5000 }, () => {
expect(detector.detect({ resourceType: "Article" })).toBe("Article");
});
it("prefers __typename over type", { timeout: 5000 }, () => {
expect(detector.detect({ __typename: "Post", type: "Other" })).toBe(
"Post"
);
});
it("returns undefined for plain object without known keys", {
timeout: 5000,
}, () => {
expect(detector.detect({ id: "1", name: "test" })).toBeUndefined();
});
it("returns undefined for null", { timeout: 5000 }, () => {
expect(detector.detect(null)).toBeUndefined();
});
it("returns undefined for undefined", { timeout: 5000 }, () => {
expect(detector.detect(undefined)).toBeUndefined();
});
it("returns undefined for primitive", { timeout: 5000 }, () => {
expect(detector.detect(42)).toBeUndefined();
expect(detector.detect("string")).toBeUndefined();
});
it("ignores empty string type", { timeout: 5000 }, () => {
expect(detector.detect({ __typename: "" })).toBeUndefined();
});
});

View File

@@ -0,0 +1,23 @@
/** Contract for detecting the resource type of an object. */
export interface SubjectDetector {
detect(obj: unknown): string | undefined;
}
/**
* Default subject detector.
*
* Looks for a `__typename`, `type`, or `resourceType` property on the object
* and returns it as the subject string. Returns `undefined` if none is found.
*/
export class DefaultSubjectDetector implements SubjectDetector {
detect(obj: unknown): string | undefined {
if (obj === null || obj === undefined) return undefined;
if (typeof obj !== "object") return undefined;
const record = obj as Record<string, unknown>;
for (const key of ["__typename", "type", "resourceType"] as const) {
const val = record[key];
if (typeof val === "string" && val.length > 0) return val;
}
return undefined;
}
}

View File

@@ -0,0 +1,17 @@
/** Built-in system role names. */
export const SYSTEM_ROLE_ADMIN = "admin";
export const SYSTEM_ROLE_VIEWER = "viewer";
export const SYSTEM_ROLE_EDITOR = "editor";
export const SYSTEM_ROLE_OWNER = "owner";
/** Wildcard permission action. */
export const WILDCARD_ACTION = "*" as const;
/** Default permission IDs for CRUD bundles. */
export const CRUD_ACTIONS = [
"create",
"read",
"update",
"delete",
"list",
] as const;

View File

@@ -0,0 +1,48 @@
# ability-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Re-exports `AbilityBuilder` and `buildAbility` from the core `ability` module for subpath import compatibility. This module exists as a subpath entry point so consumers can import the builder without pulling in the full `Ability` implementation, and to satisfy the file-per-feature layout convention for the `core/ability-builder/` directory.
`AbilityBuilder` provides a fluent chain API for constructing `Ability` instances: call `.can()` and `.cannot()` to add rules, then call `.build()` to get the computed ability.
## Features
- Fluent `.can(action, resource, conditions?)` method
- Fluent `.cannot(action, resource, conditions?)` method
- `.build()` returns a fully constructed `Ability`
- `buildAbility(fn)` convenience wrapper
## Usage: Basic Example
```ts
import { AbilityBuilder } from "@suaveplan/permissions";
const ability = new AbilityBuilder()
.can("read", "posts")
.can("create", "comments")
.cannot("delete", "*")
.build();
```
## Usage: Advanced Example
```ts
import { buildAbility } from "@suaveplan/permissions";
const adminAbility = buildAbility((b) => {
b.can("*", "*");
b.cannot("delete", "audit-logs");
});
```
## API Reference
See [ability.md](../ability/ability.md) for the full API reference.
## Implementation Notes
This module is a thin re-export wrapper. All implementation details are in `../ability/ability.ts`.

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AbilityBuilder, buildAbility } from "./ability-builder.js";
describe("ability-builder re-export", () => {
it("AbilityBuilder builds an ability", { timeout: 5000 }, () => {
const ability = new AbilityBuilder()
.can("read", "posts")
.cannot("delete", "posts")
.build();
expect(ability.can("read", "posts")).toBe(true);
expect(ability.can("delete", "posts")).toBe(false);
});
it("buildAbility from callback", { timeout: 5000 }, () => {
const ability = buildAbility((b) => {
b.can("*", "*");
});
expect(ability.can("read", "anything")).toBe(true);
});
});

View File

@@ -0,0 +1,2 @@
// Re-export AbilityBuilder from core ability module for subpath export compatibility
export { AbilityBuilder, buildAbility } from "../ability/ability.js";

View File

@@ -0,0 +1 @@
export { AbilityBuilder, buildAbility } from "./ability-builder.js";

View File

@@ -0,0 +1,97 @@
# ability
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the CASL-inspired `Ability` class and supporting `AbilityBuilder` / `buildAbility` for `@suaveplan/permissions`. An `Ability` represents the computed set of permissions for a subject as a list of `AbilityRule` objects — each rule grants or denies an action on a resource under optional ABAC conditions. Inverted (deny) rules veto permissive rules when both match, ensuring explicit prohibitions are always respected.
`AbilityBuilder` provides a fluent API for assembling rules from role metadata returned by `RoleManager.getRoleInheritanceChain`, making it straightforward to compute a user's effective ability without manual rule merging.
## Features
- `Ability.can(action, resource, context?)` — checks if the action is allowed, respecting deny vetoes
- `Ability.cannot(action, resource, context?)` — logical negation of `can`
- `Ability.rulesFor(action, resource)` — returns all matching rules for inspection
- `AbilityBuilder.can(action, resource, conditions?)` — fluent permissive rule builder
- `AbilityBuilder.cannot(action, resource, conditions?)` — fluent deny rule builder
- `buildAbility(fn)` — convenience factory accepting a builder callback
## Usage: Basic Example
```ts
import { buildAbility } from "@suaveplan/permissions";
const ability = buildAbility((b) => {
b.can("read", "posts");
b.can("create", "posts");
b.cannot("delete", "posts");
});
console.log(ability.can("read", "posts")); // true
console.log(ability.can("delete", "posts")); // false — vetoed
console.log(ability.cannot("delete", "posts")); // true
```
## Usage: Advanced Example
```ts
import { buildAbility } from "@suaveplan/permissions";
const ability = buildAbility((b) => {
b.can("*", "*"); // grant everything
b.cannot("delete", "system-roles"); // but never delete system roles
});
console.log(ability.can("create", "anything")); // true
console.log(ability.can("delete", "system-roles")); // false
// Conditional rules
const ownerAbility = buildAbility((b) => {
b.can("delete", "posts", [
{ field: "post.authorId", operator: "eq", value: "u1" },
]);
});
const ctx = { post: { authorId: "u1" } };
console.log(ownerAbility.can("delete", "posts", ctx)); // true — own post
```
## API Reference
### `Ability`
#### `can(action, resource, context?): boolean`
Returns `true` if any permissive rule matches and no inverted rule vetoes it. Evaluates conditions against `context`.
#### `cannot(action, resource, context?): boolean`
Returns `!can(action, resource, context)`.
#### `rulesFor(action, resource): AbilityRule[]`
Returns all rules (both permissive and inverted) matching the given action/resource pair.
### `AbilityBuilder`
#### `can(action, resource, conditions?): this`
Adds a permissive `AbilityRule`. Chainable.
#### `cannot(action, resource, conditions?): this`
Adds an inverted (veto) `AbilityRule`. Chainable.
#### `build(): Ability`
Returns the assembled `Ability`.
### `buildAbility(fn): Ability`
Creates an `AbilityBuilder`, passes it to `fn`, and returns the built `Ability`.
## Implementation Notes
Rule evaluation traverses `this.rules` in insertion order. When an inverted rule matches, `can` returns `false` immediately without inspecting further rules — deny-first semantics. This means more specific deny rules should be added after broader allow rules. Conditions are evaluated via `Rule.evaluateConditions` which delegates to `DefaultConditionMatcher`.

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Ability, AbilityBuilder, buildAbility } from "./ability.js";
describe("Ability.can", () => {
it("returns true for matching rule", { timeout: 5000 }, () => {
const ability = new Ability([
{ action: "read", resource: "posts", inverted: false },
]);
expect(ability.can("read", "posts")).toBe(true);
});
it("returns false when no rule matches", { timeout: 5000 }, () => {
const ability = new Ability([
{ action: "read", resource: "posts", inverted: false },
]);
expect(ability.can("delete", "posts")).toBe(false);
});
it("inverted rule vetoes permissive rule", { timeout: 5000 }, () => {
const ability = new Ability([
{ action: "*", resource: "*", inverted: false },
{ action: "delete", resource: "posts", inverted: true },
]);
expect(ability.can("delete", "posts")).toBe(false);
expect(ability.can("read", "posts")).toBe(true);
});
it("wildcard action matches any action", { timeout: 5000 }, () => {
const ability = new Ability([
{ action: "*", resource: "posts", inverted: false },
]);
expect(ability.can("create", "posts")).toBe(true);
expect(ability.can("delete", "posts")).toBe(true);
});
it("wildcard resource matches any resource", { timeout: 5000 }, () => {
const ability = new Ability([
{ action: "read", resource: "*", inverted: false },
]);
expect(ability.can("read", "comments")).toBe(true);
expect(ability.can("read", "users")).toBe(true);
});
it("evaluates conditions from context", { timeout: 5000 }, () => {
const ability = new Ability([
{
action: "delete",
resource: "posts",
conditions: [
{ field: "authorId", operator: "eq", value: "u1" },
],
inverted: false,
},
]);
expect(ability.can("delete", "posts", { authorId: "u1" })).toBe(true);
expect(ability.can("delete", "posts", { authorId: "u2" })).toBe(false);
});
});
describe("Ability.cannot", () => {
it("is negation of can", { timeout: 5000 }, () => {
const ability = new Ability([
{ action: "read", resource: "posts", inverted: false },
]);
expect(ability.cannot("read", "posts")).toBe(false);
expect(ability.cannot("delete", "posts")).toBe(true);
});
});
describe("Ability.rulesFor", () => {
it("returns matching rules", { timeout: 5000 }, () => {
const ability = new Ability([
{ action: "read", resource: "posts", inverted: false },
{ action: "delete", resource: "posts", inverted: true },
{ action: "read", resource: "comments", inverted: false },
]);
const rules = ability.rulesFor("read", "posts");
expect(rules).toHaveLength(1);
expect(rules[0]?.action).toBe("read");
});
it("returns empty array if no rules match", { timeout: 5000 }, () => {
const ability = new Ability([]);
expect(ability.rulesFor("read", "posts")).toHaveLength(0);
});
});
describe("AbilityBuilder", () => {
it("builds an ability with can and cannot", { timeout: 5000 }, () => {
const ability = new AbilityBuilder()
.can("read", "posts")
.cannot("delete", "posts")
.build();
expect(ability.can("read", "posts")).toBe(true);
expect(ability.can("delete", "posts")).toBe(false);
});
it("can is chainable", { timeout: 5000 }, () => {
const builder = new AbilityBuilder();
const result = builder.can("read", "posts");
expect(result).toBe(builder);
});
it("cannot is chainable", { timeout: 5000 }, () => {
const builder = new AbilityBuilder();
const result = builder.cannot("delete", "posts");
expect(result).toBe(builder);
});
});
describe("buildAbility", () => {
it("creates an ability from a callback", { timeout: 5000 }, () => {
const ability = buildAbility((b) => {
b.can("read", "*");
b.cannot("delete", "system");
});
expect(ability.can("read", "posts")).toBe(true);
expect(ability.can("delete", "system")).toBe(false);
});
});
describe("AbilityBuilder with conditions", () => {
it("can() with conditions spreads conditions into rule", {
timeout: 5000,
}, () => {
const ability = new AbilityBuilder()
.can("update", "posts", [
{ field: "authorId", operator: "eq", value: "u1" },
])
.build();
expect(ability.can("update", "posts", { authorId: "u1" })).toBe(true);
expect(ability.can("update", "posts", { authorId: "u2" })).toBe(false);
});
it("cannot() with conditions spreads conditions into rule", {
timeout: 5000,
}, () => {
const ability = new AbilityBuilder()
.can("*", "*")
.cannot("delete", "system", [
{ field: "env", operator: "eq", value: "prod" },
])
.build();
// condition matches → veto applies
expect(ability.can("delete", "system", { env: "prod" })).toBe(false);
// condition does not match → permissive rule wins
expect(ability.can("delete", "system", { env: "dev" })).toBe(true);
});
});

View File

@@ -0,0 +1,122 @@
import type {
AbilityRule,
PermissionAction,
PermissionCondition,
} from "../../types/permissions.types.js";
import { Rule } from "../rule/rule.js";
/**
* Represents the computed set of abilities for a subject.
*
* Rules are evaluated in order; inverted (cannot) rules take precedence over
* non-inverted (can) rules when both match the same action/resource pair.
*/
export class Ability {
protected readonly rules: AbilityRule[];
constructor(rules: AbilityRule[]) {
this.rules = rules;
}
/**
* Returns `true` if the ability allows the given action on the given resource.
*
* Inverted rules that match will veto a positive match.
*/
can(
action: string,
resource: string,
context: Record<string, unknown> = {}
): boolean {
let allowed = false;
for (const rule of this.rules) {
const r = new Rule(
rule.resource,
rule.action,
rule.conditions ?? []
);
if (!r.matches(resource, action)) continue;
if (!r.evaluateConditions(context)) continue;
if (rule.inverted) return false;
allowed = true;
}
return allowed;
}
/**
* Returns `true` if the ability explicitly denies the given action on the
* given resource.
*/
cannot(
action: string,
resource: string,
context: Record<string, unknown> = {}
): boolean {
return !this.can(action, resource, context);
}
/** Returns all rules that match the given action and resource. */
rulesFor(action: string, resource: string): AbilityRule[] {
return this.rules.filter((rule) => {
const r = new Rule(rule.resource, rule.action);
return r.matches(resource, action);
});
}
}
/**
* Fluent builder for constructing `Ability` instances.
*/
export class AbilityBuilder {
protected readonly _rules: AbilityRule[] = [];
/** Add a permissive rule. */
can(
action: PermissionAction | string,
resource: string,
conditions?: PermissionCondition[]
): this {
this._rules.push({
action,
resource,
...(conditions !== undefined && { conditions }),
inverted: false,
});
return this;
}
/** Add a prohibiting rule (veto). */
cannot(
action: PermissionAction | string,
resource: string,
conditions?: PermissionCondition[]
): this {
this._rules.push({
action,
resource,
...(conditions !== undefined && { conditions }),
inverted: true,
});
return this;
}
/** Build and return the `Ability` instance. */
build(): Ability {
return new Ability([...this._rules]);
}
}
/**
* Convenience function: build an `Ability` from a builder callback.
*
* @example
* const ability = buildAbility((b) => {
* b.can("read", "posts");
* b.cannot("delete", "posts");
* });
*/
export function buildAbility(fn: (builder: AbilityBuilder) => void): Ability {
const builder = new AbilityBuilder();
fn(builder);
return builder.build();
}

View File

@@ -0,0 +1 @@
export { Ability, AbilityBuilder, buildAbility } from "./ability.js";

View File

@@ -0,0 +1 @@
export { PermissionManager } from "./permission-manager.js";

View File

@@ -0,0 +1,77 @@
# permission-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PermissionManager` bridges permission record management and per-user permission evaluation in `@suaveplan/permissions`. It delegates persistence to a `GenericPermissionAdapter` and adds business logic: role-based permission collection with expiry filtering, wildcard action/resource support, OTel instrumentation, and optional event bus notifications on permission checks.
The `hasPermission` method performs a flat check across a user's direct role assignments — it does not traverse role inheritance. For inheritance-aware checks, use `RoleManager.getEffectivePermissions` combined with `PolicyEngine`.
## Features
- Full permission CRUD (create, update, delete, find, list)
- `hasPermission(userId, resource, action)` — additive permission check across user's roles
- `getUserPermissions(userId, siteId?)` — all deduplicated permissions for a user
- Wildcard `*` support on both resource and action
- Expiry-aware: skips expired `UserRole` assignments
- OTel counter `permissions.check.total` with `permission.result` and `permission.action` labels
- OTel histogram `permissions.check.duration_ms`
- `permission:check` event emission on optional event bus
## Usage: Basic Example
```ts
import { PermissionManager, MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
const perm = await manager.createPermission({ id: "p1", resource: "posts", action: "read" });
await adapter.createRole({ id: "r-reader", name: "Reader", permissions: [perm] });
await adapter.assignRole({ id: "ur-1", userId: "u1", roleId: "r-reader", assignedAt: new Date() });
console.log(await manager.hasPermission("u1", "posts", "read")); // true
console.log(await manager.hasPermission("u1", "posts", "delete")); // false
```
## Usage: Advanced Example
```ts
import { PermissionManager, MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
// Wildcard action
await adapter.createRole({
id: "admin",
name: "Admin",
permissions: [{ id: "p-admin", resource: "*", action: "*" }],
});
await adapter.assignRole({ id: "ur-1", userId: "admin-user", roleId: "admin", assignedAt: new Date() });
const allAllowed = await manager.hasPermission("admin-user", "any-resource", "any-action");
console.log(allAllowed); // true
```
## API Reference
### `constructor(adapter, options?)`
### `createPermission / updatePermission / deletePermission / findPermission / listPermissions`
Standard CRUD — delegates to adapter.
### `hasPermission(userId, resource, action, context?): Promise<boolean>`
Returns `true` if any non-expired role of the user contains a permission matching the resource and action (wildcard-aware).
### `getUserPermissions(userId, siteId?): Promise<Permission[]>`
Returns all deduplicated permissions from a user's non-expired roles.
## Implementation Notes
The `hasPermission` check is a flat scan over the user's direct role assignments. For deep inheritance-aware checks (e.g. admin inherits editor inherits viewer), combine with `RoleManager.getEffectivePermissions` and cache the result in `PermissionCache`. The additive model means any matching permission grants access — there is no explicit deny at the permission level; use `AbilityBuilder.cannot` for deny semantics.

View File

@@ -0,0 +1,208 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAdapter } from "../../adapters/memory/memory.js";
import type { Permission, Role } from "../../types/permissions.types.js";
import { PermissionManager } from "./permission-manager.js";
function makePerm(id: string, resource = "posts", action = "read"): Permission {
return { id, resource, action };
}
function makeRole(id: string, permissions: Permission[] = []): Role {
return { id, name: id, permissions };
}
async function setupUserWithRole(
adapter: MemoryAdapter,
userId: string,
roleId: string,
permissions: Permission[]
): Promise<void> {
await adapter.createRole(makeRole(roleId, permissions));
await adapter.assignRole({
id: `ur-${userId}-${roleId}`,
userId,
roleId,
assignedAt: new Date(),
});
}
describe("PermissionManager — CRUD", () => {
it("creates and finds a permission", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await manager.createPermission(makePerm("p1"));
expect(await manager.findPermission("p1")).toBeDefined();
});
it("updates a permission", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await manager.createPermission(makePerm("p1"));
const updated = await manager.updatePermission("p1", {
action: "write",
});
expect(updated.action).toBe("write");
});
it("deletes a permission", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await manager.createPermission(makePerm("p1"));
await manager.deletePermission("p1");
expect(await manager.findPermission("p1")).toBeNull();
});
it("lists permissions", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await manager.createPermission(makePerm("p1", "posts", "read"));
await manager.createPermission(makePerm("p2", "comments", "read"));
const all = await manager.listPermissions();
expect(all).toHaveLength(2);
const filtered = await manager.listPermissions({ resource: "posts" });
expect(filtered).toHaveLength(1);
});
});
describe("PermissionManager — hasPermission", () => {
it("returns true when user has the permission", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await setupUserWithRole(adapter, "u1", "reader", [makePerm("p1")]);
expect(await manager.hasPermission("u1", "posts", "read")).toBe(true);
});
it("returns false when user lacks the permission", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await setupUserWithRole(adapter, "u1", "reader", [makePerm("p1")]);
expect(await manager.hasPermission("u1", "posts", "delete")).toBe(
false
);
});
it("handles wildcard action", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await setupUserWithRole(adapter, "u1", "admin", [
{ id: "p-all", resource: "*", action: "*" },
]);
expect(await manager.hasPermission("u1", "anything", "anything")).toBe(
true
);
});
it("returns false for no role user", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
expect(await manager.hasPermission("nobody", "posts", "read")).toBe(
false
);
});
it("skips orphaned role (role not in store) in hasPermission", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
// Assign a role that does not exist in the roles store
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "ghost-role",
assignedAt: new Date(),
});
expect(await manager.hasPermission("u1", "posts", "read")).toBe(false);
});
it("skips expired role assignments", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await adapter.createRole(makeRole("r1", [makePerm("p1")]));
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "r1",
assignedAt: new Date(),
expiresAt: new Date(Date.now() - 1000),
});
expect(await manager.hasPermission("u1", "posts", "read")).toBe(false);
});
});
describe("PermissionManager — getUserPermissions", () => {
it("returns all permissions for a user", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await setupUserWithRole(adapter, "u1", "r1", [
makePerm("p1"),
makePerm("p2", "comments", "read"),
]);
const perms = await manager.getUserPermissions("u1");
expect(perms).toHaveLength(2);
});
it("deduplicates permissions", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
const perm = makePerm("p1");
await adapter.createRole(makeRole("r1", [perm]));
await adapter.createRole(makeRole("r2", [perm]));
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "r1",
assignedAt: new Date(),
});
await adapter.assignRole({
id: "ur2",
userId: "u1",
roleId: "r2",
assignedAt: new Date(),
});
const perms = await manager.getUserPermissions("u1");
expect(perms).toHaveLength(1);
});
it("returns empty for user with no roles", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
expect(await manager.getUserPermissions("nobody")).toEqual([]);
});
it("skips orphaned role in getUserPermissions", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "ghost-role",
assignedAt: new Date(),
});
const perms = await manager.getUserPermissions("u1");
expect(perms).toHaveLength(0);
});
it("skips expired role in getUserPermissions", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
await adapter.createRole(makeRole("r1", [makePerm("p1")]));
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "r1",
assignedAt: new Date(),
expiresAt: new Date(Date.now() - 1000), // expired
});
const perms = await manager.getUserPermissions("u1");
expect(perms).toHaveLength(0);
});
});

View File

@@ -0,0 +1,154 @@
import { metrics, trace } from "@opentelemetry/api";
import type { GenericPermissionAdapter } from "../../adapters/adapter-interface/adapter-interface.js";
import type {
Permission,
PermissionFilter,
PermissionManagerOptions,
} from "../../types/permissions.types.js";
const tracer = trace.getTracer("@suaveplan/permissions");
const meter = metrics.getMeter("@suaveplan/permissions");
const checkCounter = meter.createCounter("permissions.check.total");
const checkHistogram = meter.createHistogram("permissions.check.duration_ms", {
unit: "ms",
});
/**
* Manages permission records and evaluates whether a user holds a specific
* permission via their assigned roles.
*
* Permission checking is additive: a user has a permission if any of their
* assigned roles (transitively, including inherited roles) contains a
* permission whose `resource` and `action` match — or whose action is `*`.
*/
export class PermissionManager {
protected readonly adapter: GenericPermissionAdapter;
protected readonly options: PermissionManagerOptions;
constructor(
adapter: GenericPermissionAdapter,
options: PermissionManagerOptions = {}
) {
this.adapter = adapter;
this.options = options;
}
async createPermission(permission: Permission): Promise<Permission> {
return this.adapter.createPermission(permission);
}
async updatePermission(
id: string,
updates: Partial<Permission>
): Promise<Permission> {
return this.adapter.updatePermission(id, updates);
}
async deletePermission(id: string): Promise<void> {
return this.adapter.deletePermission(id);
}
async findPermission(id: string): Promise<Permission | null> {
return this.adapter.findPermission(id);
}
async listPermissions(filter?: PermissionFilter): Promise<Permission[]> {
return this.adapter.listPermissions(filter);
}
/**
* Check whether a user has a specific permission on a resource.
*
* Emits `permission:check` event if an event bus is configured.
*/
async hasPermission(
userId: string,
resource: string,
action: string,
_context?: Record<string, unknown>
): Promise<boolean> {
return tracer.startActiveSpan("permissions.check", async (span) => {
const start = Date.now();
try {
const userRoles = await this.adapter.getUserRoles(userId);
const now = Date.now();
let found = false;
for (const ur of userRoles) {
if (
ur.expiresAt !== undefined &&
ur.expiresAt.getTime() <= now
) {
continue;
}
const role = await this.adapter.findRole(ur.roleId);
if (!role) continue;
// Collect permissions from role (no deep inheritance here — use RoleManager for that)
for (const perm of role.permissions) {
if (
(perm.resource === "*" ||
perm.resource === resource) &&
(perm.action === "*" || perm.action === action)
) {
found = true;
break;
}
}
if (found) break;
}
const durationMs = Date.now() - start;
checkCounter.add(1, {
"permission.result": found ? "allow" : "deny",
"permission.action": action,
});
checkHistogram.record(durationMs, {
"permission.action": action,
});
span.setAttribute("permissions.allowed", found);
span.setAttribute("permissions.user_id", userId);
span.setAttribute("permissions.resource", resource);
span.setAttribute("permissions.action", action);
this.options.eventBus?.emit("permission:check", {
userId,
resource,
action,
allowed: found,
});
return found;
} finally {
span.end();
}
});
}
/**
* Return all permissions held by a user via their assigned roles.
* Optionally scoped to a site.
*/
async getUserPermissions(
userId: string,
siteId?: string
): Promise<Permission[]> {
const userRoles = await this.adapter.getUserRoles(userId, siteId);
const now = Date.now();
const seen = new Set<string>();
const perms: Permission[] = [];
for (const ur of userRoles) {
if (ur.expiresAt !== undefined && ur.expiresAt.getTime() <= now)
continue;
const role = await this.adapter.findRole(ur.roleId);
if (!role) continue;
for (const perm of role.permissions) {
if (!seen.has(perm.id)) {
seen.add(perm.id);
perms.push(perm);
}
}
}
return perms;
}
}

View File

@@ -0,0 +1 @@
export { checkPermission, PolicyEngine } from "./policy-engine.js";

View File

@@ -0,0 +1,79 @@
# policy-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PolicyEngine` is the top-level evaluation layer in `@suaveplan/permissions`. It orchestrates permission checks by delegating to `PermissionManager.hasPermission`, optionally reading from and writing to a `PermissionCache`, and returning structured `PolicyResult` objects with match metadata. It also provides `authorize` — an assertive variant that throws on denial — and `evaluateMany` for parallel batch evaluation.
## Features
- `evaluate(userId, resource, action, context?)` — single check with cache integration and OTel tracing
- `evaluateMany(requests[])` — parallel batch evaluation
- `authorize(userId, resource, action, context?)` — throws `PermissionsError` on denial
- Optional `PermissionCache` integration with `cache_hit_total` and `cache_miss_total` OTel counters
- `checkPermission` convenience function for one-off checks without engine instantiation
## Usage: Basic Example
```ts
import { PolicyEngine, PermissionManager, MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
const engine = new PolicyEngine(manager);
const result = await engine.evaluate("u1", "posts", "read");
console.log(result.allowed); // true/false
```
## Usage: Advanced Example
```ts
import { PolicyEngine, PermissionManager, PermissionCache, MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
const cache = new PermissionCache({ ttl: 30_000 });
const engine = new PolicyEngine(manager, { cache });
// authorize throws on denial
try {
await engine.authorize("u1", "posts", "delete");
} catch (err) {
console.log("Access denied");
}
// Batch evaluation
const results = await engine.evaluateMany([
{ userId: "u1", resource: "posts", action: "read" },
{ userId: "u1", resource: "posts", action: "write" },
]);
```
## API Reference
### `constructor(permissionManager, options?)`
`options.cache` — optional `PermissionCache`.
### `evaluate(userId, resource, action, context?): Promise<PolicyResult>`
Returns `{ allowed, matchedRule?, reason? }`. Reads/writes cache if configured.
### `evaluateMany(requests): Promise<PolicyResult[]>`
Parallel evaluation of multiple `PolicyRequest` objects.
### `authorize(userId, resource, action, context?): Promise<void>`
Throws `PermissionsError(PERM.PERMISSION_NOT_FOUND)` if `evaluate` returns `allowed: false`.
### `checkPermission(userId, resource, action, permissionManager): Promise<PolicyResult>`
Module-level convenience function creating a transient `PolicyEngine` for a single check.
## Implementation Notes
The `evaluateMany` method uses `Promise.all` for parallel execution — this is safe with `MemoryAdapter` but may require rate limiting or connection pooling with database adapters handling thousands of concurrent requests. The cache key is `${userId}:${resource}:${action}` — context values are not included in the key because conditions are evaluated by `PermissionManager.hasPermission` which receives the full context; the cache is therefore suitable only for context-free permission checks. If ABAC conditions vary per request, disable caching or use a context-aware key.

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAdapter } from "../../adapters/memory/memory.js";
import { PermissionCache } from "../../cache/permission-cache/permission-cache.js";
import { PermissionsError } from "../../errors/permission-error.js";
import type { Permission, Role } from "../../types/permissions.types.js";
import { PermissionManager } from "../permission-manager/permission-manager.js";
import { checkPermission, PolicyEngine } from "./policy-engine.js";
function makePerm(id: string, resource = "posts", action = "read"): Permission {
return { id, resource, action };
}
function makeRole(id: string, perms: Permission[]): Role {
return { id, name: id, permissions: perms };
}
async function setup(
permissions: Permission[],
roleId = "r1"
): Promise<{
adapter: MemoryAdapter;
manager: PermissionManager;
engine: PolicyEngine;
}> {
const adapter = new MemoryAdapter();
await adapter.createRole(makeRole(roleId, permissions));
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId,
assignedAt: new Date(),
});
const manager = new PermissionManager(adapter);
const engine = new PolicyEngine(manager);
return { adapter, manager, engine };
}
describe("PolicyEngine.evaluate", () => {
it("returns allowed:true when user has permission", {
timeout: 5000,
}, async () => {
const { engine } = await setup([makePerm("p1")]);
const result = await engine.evaluate("u1", "posts", "read");
expect(result.allowed).toBe(true);
expect(result.matchedRule).toBeTruthy();
});
it("returns allowed:false when user lacks permission", {
timeout: 5000,
}, async () => {
const { engine } = await setup([makePerm("p1")]);
const result = await engine.evaluate("u1", "posts", "delete");
expect(result.allowed).toBe(false);
expect(result.reason).toBeTruthy();
});
it("uses cache on second call", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole(makeRole("r1", [makePerm("p1")]));
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "r1",
assignedAt: new Date(),
});
const manager = new PermissionManager(adapter);
const cache = new PermissionCache({ ttl: 99999 });
const engine = new PolicyEngine(manager, { cache });
const r1 = await engine.evaluate("u1", "posts", "read");
const r2 = await engine.evaluate("u1", "posts", "read");
expect(r1.allowed).toBe(true);
expect(r2.allowed).toBe(true);
expect(r2.matchedRule).toBe("cached-allow");
});
it("caches negative result", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new PermissionManager(adapter);
const cache = new PermissionCache({ ttl: 99999 });
const engine = new PolicyEngine(manager, { cache });
const r1 = await engine.evaluate("u1", "posts", "read");
const r2 = await engine.evaluate("u1", "posts", "read");
expect(r1.allowed).toBe(false);
expect(r2.allowed).toBe(false);
expect(r2.reason).toContain("cached");
});
});
describe("PolicyEngine.evaluateMany", () => {
it("evaluates multiple requests", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole(makeRole("r1", [makePerm("p1")]));
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "r1",
assignedAt: new Date(),
});
const manager = new PermissionManager(adapter);
const engine = new PolicyEngine(manager);
const results = await engine.evaluateMany([
{ userId: "u1", resource: "posts", action: "read" },
{ userId: "u1", resource: "posts", action: "delete" },
]);
expect(results[0]?.allowed).toBe(true);
expect(results[1]?.allowed).toBe(false);
});
});
describe("PolicyEngine.authorize", () => {
it("does not throw when allowed", { timeout: 5000 }, async () => {
const { engine } = await setup([makePerm("p1")]);
await expect(
engine.authorize("u1", "posts", "read")
).resolves.toBeUndefined();
});
it("throws PermissionsError when denied", { timeout: 5000 }, async () => {
const { engine } = await setup([makePerm("p1")]);
await expect(
engine.authorize("u1", "posts", "delete")
).rejects.toBeInstanceOf(PermissionsError);
});
});
describe("checkPermission", () => {
it("evaluates a one-off permission check", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole(makeRole("r1", [makePerm("p1")]));
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "r1",
assignedAt: new Date(),
});
const manager = new PermissionManager(adapter);
const result = await checkPermission("u1", "posts", "read", manager);
expect(result.allowed).toBe(true);
});
});

View File

@@ -0,0 +1,138 @@
import { metrics, trace } from "@opentelemetry/api";
import type { PermissionCache } from "../../cache/permission-cache/permission-cache.js";
import { PermissionDeniedError } from "../../errors/permission-error.js";
import type {
PolicyEngineOptions,
PolicyRequest,
PolicyResult,
} from "../../types/permissions.types.js";
import type { PermissionManager } from "../permission-manager/permission-manager.js";
const tracer = trace.getTracer("@suaveplan/permissions");
const meter = metrics.getMeter("@suaveplan/permissions");
const cacheHitCounter = meter.createCounter("permissions.cache_hit_total");
const cacheMissCounter = meter.createCounter("permissions.cache_miss_total");
/**
* Evaluates permission requests against the permission manager with an
* optional caching layer.
*
* Cache keys follow the pattern `${userId}:${resource}:${action}`.
*/
export class PolicyEngine {
protected readonly permissionManager: PermissionManager;
protected readonly cache?: PermissionCache;
constructor(
permissionManager: PermissionManager,
options: PolicyEngineOptions = {}
) {
this.permissionManager = permissionManager;
if (options.cache !== undefined) {
this.cache = options.cache;
}
}
/**
* Evaluate a single permission request.
*
* Returns a `PolicyResult` with `allowed`, optional `matchedRule`, and
* `reason`.
*/
async evaluate(
userId: string,
resource: string,
action: string,
context?: Record<string, unknown>
): Promise<PolicyResult> {
return tracer.startActiveSpan(
"permissions.policy.evaluate",
async (span) => {
try {
const cacheKey = `${userId}:${resource}:${action}`;
const cached = this.cache?.get(cacheKey);
if (cached !== undefined) {
cacheHitCounter.add(1);
span.setAttribute("permissions.cache_hit", true);
return {
allowed: cached,
...(cached
? { matchedRule: "cached-allow" }
: { reason: "Permission denied (cached)" }),
};
}
cacheMissCounter.add(1);
span.setAttribute("permissions.cache_hit", false);
const allowed = await this.permissionManager.hasPermission(
userId,
resource,
action,
context
);
this.cache?.set(cacheKey, allowed);
span.setAttribute("permissions.allowed", allowed);
span.setAttribute("permissions.user_id", userId);
return {
allowed,
...(allowed
? { matchedRule: `${resource}:${action}` }
: {
reason: `User "${userId}" lacks ${action} on ${resource}`,
}),
};
} finally {
span.end();
}
}
);
}
/**
* Evaluate multiple permission requests in parallel.
*/
async evaluateMany(requests: PolicyRequest[]): Promise<PolicyResult[]> {
return Promise.all(
requests.map((r) =>
this.evaluate(r.userId, r.resource, r.action, r.context)
)
);
}
/**
* Assert that a user has a permission.
*
* @throws `PermissionsError(PERM.PERMISSION_NOT_FOUND)` if denied
*/
async authorize(
userId: string,
resource: string,
action: string,
context?: Record<string, unknown>
): Promise<void> {
const result = await this.evaluate(userId, resource, action, context);
if (!result.allowed) {
throw new PermissionDeniedError({
message:
result.reason ??
/* istanbul ignore next */ `Access denied: ${action} on ${resource}`,
});
}
}
}
/**
* Convenience function for a one-off permission check without creating a full engine.
*/
export async function checkPermission(
userId: string,
resource: string,
action: string,
permissionManager: PermissionManager
): Promise<PolicyResult> {
const engine = new PolicyEngine(permissionManager);
return engine.evaluate(userId, resource, action);
}

View File

@@ -0,0 +1 @@
export { RoleManager } from "./role-manager.js";

View File

@@ -0,0 +1,89 @@
# role-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RoleManager` provides the role lifecycle and user-role assignment management layer for `@suaveplan/permissions`. It delegates persistence to a `GenericPermissionAdapter` while adding business logic: system role protection, expiry-aware role resolution, circular inheritance detection (DFS), and OTel instrumentation.
`getRoleInheritanceChain` performs a depth-first traversal of the `inherits` graph, detecting cycles and returning all ancestor roles in traversal order. `getEffectivePermissions` then flattens all permissions across the inheritance chain, deduplicating by `permission.id`.
## Features
- Full role CRUD via adapter delegation (with system-role mutation guards)
- `assignRole(userId, roleId, options?)` — creates a `UserRole` with optional expiry, emits `permission:grant` event
- `revokeRole(userId, roleId, siteId?)` — removes assignment, emits `permission:revoke` event
- `getUserRoles(userId, siteId?)` — returns non-expired `Role` objects for a user
- `getRoleInheritanceChain(roleId)` — BFS/DFS traversal with circular dependency detection
- `getEffectivePermissions(roleId)` — deduplicated flat permissions from full inheritance chain
- OTel counters: `permissions.role_assign_total`, `permissions.role_revoke_total`
- OTel histogram: `permissions.inheritance_depth`
## Usage: Basic Example
```ts
import { RoleManager, MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await manager.createRole({ id: "viewer", name: "Viewer", permissions: [] });
await manager.assignRole("user-1", "viewer");
const roles = await manager.getUserRoles("user-1");
console.log(roles.map(r => r.name)); // ["Viewer"]
```
## Usage: Advanced Example
```ts
import { RoleManager, MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
// Build an inheritance chain: super-admin inherits admin inherits viewer
await adapter.createRole({ id: "viewer", name: "Viewer", permissions: [readPerm] });
await adapter.createRole({ id: "admin", name: "Admin", permissions: [writePerm], inherits: ["viewer"] });
await adapter.createRole({ id: "super-admin", name: "Super Admin", permissions: [managePerm], inherits: ["admin"] });
const chain = await manager.getRoleInheritanceChain("super-admin");
console.log(chain.map(r => r.id)); // ["super-admin", "admin", "viewer"]
const perms = await manager.getEffectivePermissions("super-admin");
// [managePerm, writePerm, readPerm] — deduplicated
```
## API Reference
### `constructor(adapter, options?, idGenerator?)`
### `createRole(role): Promise<Role>`
### `updateRole(id, updates): Promise<Role>`
Throws `SystemRoleMutationError` for system roles.
### `deleteRole(id): Promise<void>`
Throws `SystemRoleMutationError` for system roles.
### `assignRole(userId, roleId, options?): Promise<UserRole>`
Options: `siteId?`, `expiresAt?`, `assignedBy?`.
### `revokeRole(userId, roleId, siteId?): Promise<void>`
### `getUserRoles(userId, siteId?): Promise<Role[]>`
Returns non-expired roles for the user.
### `getRoleInheritanceChain(roleId): Promise<Role[]>`
Throws `CircularInheritanceError` on cycles, `RoleNotFoundError` on missing roles.
### `getEffectivePermissions(roleId): Promise<Permission[]>`
## Implementation Notes
The DFS traversal in `getRoleInheritanceChain` uses a `path` array to track the current traversal path (for cycle detection) and a `visited` set to avoid re-processing nodes already in the chain. The `visited` check comes after the cycle check so that the cycle error is thrown with the full offending path, not silently skipped.

View File

@@ -0,0 +1,257 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAdapter } from "../../adapters/memory/memory.js";
import {
CircularInheritanceError,
RoleNotFoundError,
SystemRoleMutationError,
} from "../../errors/permission-error.js";
import type { Permission, Role } from "../../types/permissions.types.js";
import { RoleManager } from "./role-manager.js";
function makeRole(overrides: Partial<Role> = {}): Role {
return {
id: "r-1",
name: "Viewer",
permissions: [],
...overrides,
};
}
function makePerm(id: string): Permission {
return { id, resource: "posts", action: "read" };
}
describe("RoleManager — CRUD", () => {
it("creates and retrieves a role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await manager.createRole(makeRole());
const role = await manager.findRole("r-1");
expect(role?.name).toBe("Viewer");
});
it("updates a role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await manager.createRole(makeRole());
const updated = await manager.updateRole("r-1", { name: "Editor" });
expect(updated.name).toBe("Editor");
});
it("throws SystemRoleMutationError on update of system role", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await manager.createRole(makeRole({ isSystem: true }));
await expect(
manager.updateRole("r-1", { name: "x" })
).rejects.toBeInstanceOf(SystemRoleMutationError);
});
it("throws RoleNotFoundError on update of missing role", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await expect(manager.updateRole("nope", {})).rejects.toBeInstanceOf(
RoleNotFoundError
);
});
it("deletes a role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await manager.createRole(makeRole());
await manager.deleteRole("r-1");
expect(await manager.findRole("r-1")).toBeNull();
});
it("throws SystemRoleMutationError on delete of system role", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await manager.createRole(makeRole({ isSystem: true }));
await expect(manager.deleteRole("r-1")).rejects.toBeInstanceOf(
SystemRoleMutationError
);
});
it("throws RoleNotFoundError on delete of missing role", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await expect(manager.deleteRole("nope")).rejects.toBeInstanceOf(
RoleNotFoundError
);
});
});
describe("RoleManager — Assignments", () => {
it("assigns and retrieves roles for a user", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await adapter.createRole(makeRole({ id: "viewer" }));
await manager.assignRole("u1", "viewer");
const roles = await manager.getUserRoles("u1");
expect(roles).toHaveLength(1);
expect(roles[0]?.id).toBe("viewer");
});
it("filters expired roles", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await adapter.createRole(makeRole({ id: "viewer" }));
await manager.assignRole("u1", "viewer", {
expiresAt: new Date(Date.now() - 1000),
});
const roles = await manager.getUserRoles("u1");
expect(roles).toHaveLength(0);
});
it("revokes a role", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await adapter.createRole(makeRole({ id: "viewer" }));
await manager.assignRole("u1", "viewer");
await manager.revokeRole("u1", "viewer");
expect(await manager.getUserRoles("u1")).toHaveLength(0);
});
});
describe("RoleManager — Inheritance", () => {
it("traverses inheritance chain", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await adapter.createRole(makeRole({ id: "c", name: "C" }));
await adapter.createRole(
makeRole({ id: "b", name: "B", inherits: ["c"] })
);
await adapter.createRole(
makeRole({ id: "a", name: "A", inherits: ["b"] })
);
const chain = await manager.getRoleInheritanceChain("a");
expect(chain.map((r) => r.id)).toEqual(["a", "b", "c"]);
});
it("throws CircularInheritanceError for cycles", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await adapter.createRole(makeRole({ id: "x", inherits: ["y"] }));
await adapter.createRole(makeRole({ id: "y", inherits: ["x"] }));
await expect(
manager.getRoleInheritanceChain("x")
).rejects.toBeInstanceOf(CircularInheritanceError);
});
it("throws RoleNotFoundError for missing inherited role", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await adapter.createRole(makeRole({ id: "a", inherits: ["missing"] }));
await expect(
manager.getRoleInheritanceChain("a")
).rejects.toBeInstanceOf(RoleNotFoundError);
});
it("collects effective permissions", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
const pA = makePerm("p-a");
const pB = makePerm("p-b");
await adapter.createRole(makeRole({ id: "base", permissions: [pA] }));
await adapter.createRole(
makeRole({ id: "extended", permissions: [pB], inherits: ["base"] })
);
const perms = await manager.getEffectivePermissions("extended");
expect(perms.map((p) => p.id)).toEqual(["p-b", "p-a"]);
});
it("deduplicates permissions", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
const pA = makePerm("p-a");
await adapter.createRole(makeRole({ id: "base", permissions: [pA] }));
await adapter.createRole(
makeRole({ id: "dupe", permissions: [pA], inherits: ["base"] })
);
const perms = await manager.getEffectivePermissions("dupe");
expect(perms).toHaveLength(1);
});
});
describe("RoleManager — listRoles", () => {
it("lists all roles", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await manager.createRole(makeRole({ id: "r1" }));
await manager.createRole(makeRole({ id: "r2" }));
const roles = await manager.listRoles();
expect(roles).toHaveLength(2);
});
});
describe("RoleManager — assignRole options", () => {
it("assigns role with siteId, expiresAt, and assignedBy", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
await adapter.createRole(makeRole({ id: "viewer" }));
const result = await manager.assignRole("u1", "viewer", {
siteId: "site-1",
expiresAt: new Date(Date.now() + 99999),
assignedBy: "admin-user",
});
expect(result.siteId).toBe("site-1");
expect(result.assignedBy).toBe("admin-user");
expect(result.expiresAt).toBeDefined();
});
it("getUserRoles skips orphaned role (findRole returns null)", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
// Directly assign a role ID that does not exist in the role store
await adapter.assignRole({
id: "ur-1",
userId: "u1",
roleId: "ghost-role",
assignedAt: new Date(),
});
const roles = await manager.getUserRoles("u1");
// Ghost role is not in the store → should be skipped, returning empty
expect(roles).toHaveLength(0);
});
it("getRoleInheritanceChain handles diamond inheritance without loops", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const manager = new RoleManager(adapter);
// Diamond: a → b, a → c, both b and c → d
await adapter.createRole(makeRole({ id: "d", name: "D" }));
await adapter.createRole(
makeRole({ id: "b", name: "B", inherits: ["d"] })
);
await adapter.createRole(
makeRole({ id: "c", name: "C", inherits: ["d"] })
);
await adapter.createRole(
makeRole({ id: "a", name: "A", inherits: ["b", "c"] })
);
// Should traverse without error; 'd' visited twice but deduped
const chain = await manager.getRoleInheritanceChain("a");
const ids = chain.map((r) => r.id);
expect(ids).toContain("d");
expect(ids.filter((id) => id === "d")).toHaveLength(1);
});
});

View File

@@ -0,0 +1,215 @@
import { metrics, trace } from "@opentelemetry/api";
import type { IdGenerator } from "@suaveplan/types";
import type { GenericPermissionAdapter } from "../../adapters/adapter-interface/adapter-interface.js";
import {
CircularInheritanceError,
RoleNotFoundError,
SystemRoleMutationError,
} from "../../errors/permission-error.js";
import type {
Permission,
Role,
RoleAssignOptions,
RoleFilter,
RoleManagerOptions,
UserRole,
} from "../../types/permissions.types.js";
const tracer = trace.getTracer("@suaveplan/permissions");
const meter = metrics.getMeter("@suaveplan/permissions");
const roleAssignCounter = meter.createCounter("permissions.role_assign_total");
const roleRevokeCounter = meter.createCounter("permissions.role_revoke_total");
const inheritanceDepthHistogram = meter.createHistogram(
"permissions.inheritance_depth"
);
/**
* Manages roles and user-role assignments via a `GenericPermissionAdapter`.
*
* Supports role inheritance with circular-dependency detection (DFS),
* expiry-aware role resolution, and optional `EventBusLike` notifications.
*/
export class RoleManager {
protected readonly adapter: GenericPermissionAdapter;
protected readonly options: RoleManagerOptions;
protected readonly idGenerator: IdGenerator;
constructor(
adapter: GenericPermissionAdapter,
options: RoleManagerOptions = {},
idGenerator: IdGenerator = () => crypto.randomUUID()
) {
this.adapter = adapter;
this.options = options;
this.idGenerator = idGenerator;
}
async createRole(role: Role): Promise<Role> {
return this.adapter.createRole(role);
}
async updateRole(id: string, updates: Partial<Role>): Promise<Role> {
const existing = await this.adapter.findRole(id);
if (!existing) throw new RoleNotFoundError(id);
if (existing.isSystem) throw new SystemRoleMutationError(id);
return this.adapter.updateRole(id, updates);
}
async deleteRole(id: string): Promise<void> {
const existing = await this.adapter.findRole(id);
if (!existing) throw new RoleNotFoundError(id);
if (existing.isSystem) throw new SystemRoleMutationError(id);
return this.adapter.deleteRole(id);
}
async findRole(id: string): Promise<Role | null> {
return this.adapter.findRole(id);
}
async listRoles(filter?: RoleFilter): Promise<Role[]> {
return this.adapter.listRoles(filter);
}
/**
* Assign a role to a user.
*
* Emits `permission:grant` on the event bus if configured.
*/
async assignRole(
userId: string,
roleId: string,
options: RoleAssignOptions = {}
): Promise<UserRole> {
return tracer.startActiveSpan(
"permissions.role.assign",
async (span) => {
try {
const userRole: UserRole = {
id: this.idGenerator(),
userId,
roleId,
...(options.siteId !== undefined && {
siteId: options.siteId,
}),
assignedAt: new Date(),
...(options.expiresAt !== undefined && {
expiresAt: options.expiresAt,
}),
...(options.assignedBy !== undefined && {
assignedBy: options.assignedBy,
}),
};
const result = await this.adapter.assignRole(userRole);
roleAssignCounter.add(1, { "role.id": roleId });
span.setAttribute("permissions.role_id", roleId);
span.setAttribute("permissions.user_id", userId);
this.options.eventBus?.emit("permission:grant", {
userId,
roleId,
siteId: options.siteId,
});
return result;
} finally {
span.end();
}
}
);
}
/**
* Revoke a role from a user.
*
* Emits `permission:revoke` on the event bus if configured.
*/
async revokeRole(
userId: string,
roleId: string,
siteId?: string
): Promise<void> {
return tracer.startActiveSpan(
"permissions.role.revoke",
async (span) => {
try {
await this.adapter.revokeRole(userId, roleId, siteId);
roleRevokeCounter.add(1, { "role.id": roleId });
span.setAttribute("permissions.role_id", roleId);
this.options.eventBus?.emit("permission:revoke", {
userId,
roleId,
siteId,
});
} finally {
span.end();
}
}
);
}
/**
* Return the effective `Role` objects currently assigned to a user,
* filtered by expiry.
*/
async getUserRoles(userId: string, siteId?: string): Promise<Role[]> {
const userRoles = await this.adapter.getUserRoles(userId, siteId);
const now = Date.now();
const roles: Role[] = [];
for (const ur of userRoles) {
if (ur.expiresAt !== undefined && ur.expiresAt.getTime() <= now)
continue;
const role = await this.adapter.findRole(ur.roleId);
if (role !== null) roles.push(role);
}
return roles;
}
/**
* Resolve the full inheritance chain for a role.
*
* Returns all ancestor roles in BFS order.
* Throws `CircularInheritanceError` if a cycle is detected.
*/
async getRoleInheritanceChain(roleId: string): Promise<Role[]> {
const chain: Role[] = [];
const visited = new Set<string>();
const path: string[] = [];
const traverse = async (id: string): Promise<void> => {
if (path.includes(id)) {
throw new CircularInheritanceError([...path, id]);
}
if (visited.has(id)) return;
visited.add(id);
path.push(id);
const role = await this.adapter.findRole(id);
if (!role) throw new RoleNotFoundError(id);
chain.push(role);
for (const parentId of role.inherits ?? []) {
await traverse(parentId);
}
path.pop();
};
await traverse(roleId);
inheritanceDepthHistogram.record(chain.length, { "role.id": roleId });
return chain;
}
/**
* Collect all permissions from a role's full inheritance chain,
* deduplicating by `permission.id`.
*/
async getEffectivePermissions(roleId: string): Promise<Permission[]> {
const chain = await this.getRoleInheritanceChain(roleId);
const seen = new Set<string>();
const perms: Permission[] = [];
for (const role of chain) {
for (const perm of role.permissions) {
if (!seen.has(perm.id)) {
seen.add(perm.id);
perms.push(perm);
}
}
}
return perms;
}
}

View File

@@ -0,0 +1 @@
export { Rule } from "./rule.js";

View File

@@ -0,0 +1,73 @@
# rule
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Rule` is the atomic unit of access control in `@suaveplan/permissions`. Each rule pairs a resource type with an action and an optional list of `PermissionCondition` objects for ABAC evaluation. Rules are created by `AbilityBuilder` and collected into `Ability` instances; `PolicyEngine` uses rules to evaluate permission requests against a user's role set.
Wildcard support (`*`) on both `action` and `resource` enables concise all-or-nothing rules such as "manage all resources" without enumerating every action/resource combination.
## Features
- `matches(resource, action): boolean` — wildcard-aware matching
- `evaluateConditions(context): boolean` — ABAC evaluation using `DefaultConditionMatcher`
- Constructor: `(resource, action, conditions?)`
- Empty conditions array defaults to `true` (unconditional rule)
## Usage: Basic Example
```ts
import { Rule } from "@suaveplan/permissions";
const rule = new Rule("posts", "read");
console.log(rule.matches("posts", "read")); // true
console.log(rule.matches("posts", "delete")); // false
```
## Usage: Advanced Example
```ts
import { Rule } from "@suaveplan/permissions";
const ownerOnly = new Rule("posts", "delete", [
{ field: "post.authorId", operator: "eq", value: "${userId}" },
]);
const ctx = { post: { authorId: "u1" }, userId: "u1" };
console.log(ownerOnly.matches("posts", "delete")); // true
console.log(ownerOnly.evaluateConditions(ctx)); // true
```
## API Reference
### `constructor(resource, action, conditions?)`
- `resource` — resource type or `"*"` wildcard
- `action` — `PermissionAction | string` or `"*"` wildcard
- `conditions` — optional `PermissionCondition[]` (default: `[]`)
### `matches(resource, action): boolean`
Returns `true` if both resource and action match (including wildcards).
### `evaluateConditions(context): boolean`
Returns `true` if all conditions pass, or if the conditions array is empty.
### `resource: string`
The resource type this rule applies to.
### `action: PermissionAction | string`
The action this rule applies to.
### `conditions: PermissionCondition[]`
The list of ABAC conditions (may be empty).
## Implementation Notes
The `DefaultConditionMatcher` instance is created once per `Rule` instance and reused across all `evaluateConditions` calls. For hot paths where thousands of `Rule` instances are created per request, consider sharing a single `DefaultConditionMatcher` across all rules via dependency injection. The `protected` modifier on `conditionMatcher` allows test subclasses to inject a custom matcher for deterministic condition testing.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { Rule } from "./rule.js";
describe("Rule.matches", () => {
it("matches exact resource and action", { timeout: 5000 }, () => {
const r = new Rule("posts", "read");
expect(r.matches("posts", "read")).toBe(true);
});
it("does not match wrong action", { timeout: 5000 }, () => {
const r = new Rule("posts", "read");
expect(r.matches("posts", "delete")).toBe(false);
});
it("does not match wrong resource", { timeout: 5000 }, () => {
const r = new Rule("posts", "read");
expect(r.matches("comments", "read")).toBe(false);
});
it("wildcard action matches any action", { timeout: 5000 }, () => {
const r = new Rule("posts", "*");
expect(r.matches("posts", "delete")).toBe(true);
expect(r.matches("posts", "read")).toBe(true);
});
it("wildcard resource matches any resource", { timeout: 5000 }, () => {
const r = new Rule("*", "read");
expect(r.matches("posts", "read")).toBe(true);
expect(r.matches("comments", "read")).toBe(true);
});
it("both wildcards match everything", { timeout: 5000 }, () => {
const r = new Rule("*", "*");
expect(r.matches("anything", "anything")).toBe(true);
});
});
describe("Rule.evaluateConditions", () => {
it("returns true with no conditions", { timeout: 5000 }, () => {
const r = new Rule("posts", "read");
expect(r.evaluateConditions({})).toBe(true);
});
it("passes when all conditions match", { timeout: 5000 }, () => {
const r = new Rule("posts", "read", [
{ field: "role", operator: "eq", value: "admin" },
]);
expect(r.evaluateConditions({ role: "admin" })).toBe(true);
});
it("fails when one condition does not match", { timeout: 5000 }, () => {
const r = new Rule("posts", "read", [
{ field: "role", operator: "eq", value: "admin" },
{ field: "active", operator: "eq", value: true },
]);
expect(r.evaluateConditions({ role: "admin", active: false })).toBe(
false
);
});
it("accesses nested field via dot notation", { timeout: 5000 }, () => {
const r = new Rule("posts", "delete", [
{ field: "post.authorId", operator: "eq", value: "u1" },
]);
expect(r.evaluateConditions({ post: { authorId: "u1" } })).toBe(true);
expect(r.evaluateConditions({ post: { authorId: "u2" } })).toBe(false);
});
});

View File

@@ -0,0 +1,45 @@
import { DefaultConditionMatcher } from "../../conditions/condition-matcher/condition-matcher.js";
import type {
PermissionAction,
PermissionCondition,
} from "../../types/permissions.types.js";
/**
* A single named rule pairing an action with a resource, with optional ABAC conditions.
*
* Rules are used by `Ability` to determine whether a given action on a resource
* is permitted. Wildcards (`*`) on either `action` or `resource` match any value.
*/
export class Rule {
readonly resource: string;
readonly action: PermissionAction | string;
readonly conditions: PermissionCondition[];
protected readonly conditionMatcher = new DefaultConditionMatcher();
constructor(
resource: string,
action: PermissionAction | string,
conditions: PermissionCondition[] = []
) {
this.resource = resource;
this.action = action;
this.conditions = conditions;
}
/** Returns `true` if this rule matches the given resource and action. */
matches(resource: string, action: string): boolean {
const actionMatch = this.action === "*" || this.action === action;
const resourceMatch =
this.resource === "*" || this.resource === resource;
return actionMatch && resourceMatch;
}
/** Evaluate all conditions against a context. Returns `true` if all pass (or no conditions). */
evaluateConditions(context: Record<string, unknown>): boolean {
if (this.conditions.length === 0) return true;
return this.conditions.every((c) =>
this.conditionMatcher.matches(c, context)
);
}
}

View File

@@ -0,0 +1,12 @@
export {
AssignmentNotFoundError,
CircularInheritanceError,
PERM_CODES,
PermissionNotFoundError,
PermissionsAdapterError,
PermissionsCacheError,
PermissionsError,
type PermissionsErrorCode,
RoleNotFoundError,
SystemRoleMutationError,
} from "./permission-error.js";

View File

@@ -0,0 +1,100 @@
# permission-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the complete error taxonomy for `@suaveplan/permissions`. Every thrown error is an instance of `PermissionsError`, which extends `SuaveplanError` from `@suaveplan/error`. Stable error codes are registered via `defineCodes("PERM", [...])` — a compact registry that ensures codes follow the `PERM.<NAME>` pattern, are unique across the application, and survive refactors without breaking serialised error payloads.
Each concrete subclass carries relevant context as typed properties (e.g. `roleId`, `permissionId`, `chain`) passed to the base class's structured `context` object, enabling downstream log sinks and OTel exporters to extract machine-readable data without parsing message strings.
## Features
- Stable `PERM_CODES` registry (seven codes covering roles, permissions, assignments, inheritance, system protection, and storage)
- `PermissionsError` base class — catch-all for the package
- `RoleNotFoundError` — typed `roleId` property
- `PermissionNotFoundError` — typed `permissionId` property
- `AssignmentNotFoundError` — typed `userId` + `roleId` context
- `CircularInheritanceError` — typed `chain: string[]` of the offending role path
- `SystemRoleMutationError` — typed `roleId` of the protected role
- `PermissionsAdapterError` — wraps underlying storage errors
- `PermissionsCacheError` — wraps cache-layer errors
## Usage: Basic Example
```ts
import { RoleNotFoundError, PERM_CODES } from "@suaveplan/permissions/errors";
try {
await roleManager.createRole({ id: "admin", inherits: ["nonexistent"] });
} catch (err) {
if (err instanceof RoleNotFoundError) {
console.error("Missing role:", err.roleId);
}
}
```
## Usage: Advanced Example
```ts
import { PermissionsError, PERM_CODES, CircularInheritanceError } from "@suaveplan/permissions/errors";
function handlePermissionsError(err: unknown): Response {
if (!(err instanceof PermissionsError)) throw err;
switch (err.code) {
case PERM_CODES.CIRCULAR_INHERITANCE:
return new Response("Role inheritance loop detected", { status: 409 });
case PERM_CODES.ROLE_NOT_FOUND:
return new Response("Role not found", { status: 404 });
case PERM_CODES.SYSTEM_ROLE_MUTATION:
return new Response("Cannot modify system role", { status: 403 });
default:
return new Response("Permissions error", { status: 500 });
}
}
```
## API Reference
### `PERM_CODES`
`Record<string, string>` from `defineCodes("PERM", [...])`.
Keys: `ROLE_NOT_FOUND`, `PERMISSION_NOT_FOUND`, `ASSIGNMENT_NOT_FOUND`, `CIRCULAR_INHERITANCE`, `SYSTEM_ROLE_MUTATION`, `ADAPTER_ERROR`, `CACHE_ERROR`.
### `PermissionsError`
Base class. Extends `SuaveplanError<PermissionsErrorCode>`.
### `RoleNotFoundError(roleId: string)`
Property `roleId: string`.
### `PermissionNotFoundError(permissionId: string)`
Property `permissionId: string`.
### `AssignmentNotFoundError({ userId, roleId })`
Structured context with `userId` and `roleId`.
### `CircularInheritanceError(chain: string[])`
Property `chain: string[]` — the full inheritance path forming the loop.
### `SystemRoleMutationError(roleId: string)`
Property `roleId: string`.
### `PermissionsAdapterError({ message?, cause? })`
Wraps storage errors. Cause is only set if the raw cause is an `Error` instance.
### `PermissionsCacheError({ message?, cause? })`
Wraps cache errors with optional cause wrapping.
## Implementation Notes
The `PERM_CODES` registry follows the same pattern as every other package in the project: `defineCodes("PERM", [...])` produces deterministic string codes like `"PERM.ROLE_NOT_FOUND"`. Cause objects are always guarded with `instanceof Error` before being passed to the base class to prevent serialisation issues with non-Error thrown values. The `CircularInheritanceError` exposes the full `chain` array so callers can display a meaningful error message rather than having to re-detect the loop.

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AssignmentNotFoundError,
CircularInheritanceError,
PERM_CODES,
PermissionDeniedError,
PermissionNotFoundError,
PermissionsAdapterError,
PermissionsCacheError,
PermissionsError,
RoleNotFoundError,
SystemRoleMutationError,
} from "./permission-error.js";
describe("PERM_CODES", () => {
it("has all expected keys", { timeout: 5000 }, () => {
expect(PERM_CODES.ROLE_NOT_FOUND).toBeDefined();
expect(PERM_CODES.PERMISSION_NOT_FOUND).toBeDefined();
expect(PERM_CODES.ASSIGNMENT_NOT_FOUND).toBeDefined();
expect(PERM_CODES.CIRCULAR_INHERITANCE).toBeDefined();
expect(PERM_CODES.SYSTEM_ROLE_MUTATION).toBeDefined();
expect(PERM_CODES.ADAPTER_ERROR).toBeDefined();
expect(PERM_CODES.CACHE_ERROR).toBeDefined();
});
it("all codes start with PERM prefix", { timeout: 5000 }, () => {
for (const code of Object.values(PERM_CODES)) {
expect(code).toMatch(/^PERM\./);
}
});
});
describe("RoleNotFoundError", () => {
it("is a PermissionsError", { timeout: 5000 }, () => {
const err = new RoleNotFoundError("admin");
expect(err).toBeInstanceOf(PermissionsError);
expect(err.code).toBe(PERM_CODES.ROLE_NOT_FOUND);
});
it("stores roleId", { timeout: 5000 }, () => {
const err = new RoleNotFoundError("editor");
expect(err.roleId).toBe("editor");
});
it("includes roleId in message", { timeout: 5000 }, () => {
const err = new RoleNotFoundError("viewer");
expect(err.message).toContain("viewer");
});
});
describe("PermissionNotFoundError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new PermissionNotFoundError("perm-1");
expect(err.code).toBe(PERM_CODES.PERMISSION_NOT_FOUND);
expect(err.permissionId).toBe("perm-1");
});
});
describe("AssignmentNotFoundError", () => {
it("has correct code and context", { timeout: 5000 }, () => {
const err = new AssignmentNotFoundError({ userId: "u1", roleId: "r1" });
expect(err.code).toBe(PERM_CODES.ASSIGNMENT_NOT_FOUND);
expect(err.message).toContain("u1");
expect(err.message).toContain("r1");
});
});
describe("CircularInheritanceError", () => {
it("stores chain and has correct code", { timeout: 5000 }, () => {
const chain = ["A", "B", "C", "A"];
const err = new CircularInheritanceError(chain);
expect(err.code).toBe(PERM_CODES.CIRCULAR_INHERITANCE);
expect(err.chain).toEqual(chain);
expect(err.message).toContain("A → B → C → A");
});
});
describe("SystemRoleMutationError", () => {
it("stores roleId", { timeout: 5000 }, () => {
const err = new SystemRoleMutationError("superadmin");
expect(err.code).toBe(PERM_CODES.SYSTEM_ROLE_MUTATION);
expect(err.roleId).toBe("superadmin");
});
});
describe("PermissionsAdapterError", () => {
it("has default message", { timeout: 5000 }, () => {
const err = new PermissionsAdapterError();
expect(err.code).toBe(PERM_CODES.ADAPTER_ERROR);
});
it("wraps Error cause", { timeout: 5000 }, () => {
const cause = new Error("db fail");
const err = new PermissionsAdapterError({ cause });
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new PermissionsAdapterError({ cause: "string" });
expect(err.cause).toBeUndefined();
});
});
describe("PermissionsCacheError", () => {
it("has correct code", { timeout: 5000 }, () => {
const err = new PermissionsCacheError();
expect(err.code).toBe(PERM_CODES.CACHE_ERROR);
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new PermissionsCacheError({ message: "cache full" });
expect(err.message).toBe("cache full");
});
it("wraps Error cause", { timeout: 5000 }, () => {
const cause = new Error("cache fail");
const err = new PermissionsCacheError({ cause });
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause", { timeout: 5000 }, () => {
const err = new PermissionsCacheError({ cause: "string cause" });
expect(err.cause).toBeUndefined();
});
});
describe("PermissionDeniedError", () => {
it("has PERMISSION_NOT_FOUND code", { timeout: 5000 }, () => {
const err = new PermissionDeniedError();
expect(err.code).toBe(PERM_CODES.PERMISSION_NOT_FOUND);
expect(err.message).toBe("Permission denied");
});
it("accepts custom message", { timeout: 5000 }, () => {
const err = new PermissionDeniedError({ message: "No access" });
expect(err.message).toBe("No access");
});
it("accepts userId in context", { timeout: 5000 }, () => {
const err = new PermissionDeniedError({ userId: "u1" });
expect(err).toBeInstanceOf(PermissionsError);
});
});

View File

@@ -0,0 +1,119 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error codes for the permissions package. */
export const PERM_CODES = defineCodes("PERM", [
"ROLE_NOT_FOUND",
"PERMISSION_NOT_FOUND",
"ASSIGNMENT_NOT_FOUND",
"CIRCULAR_INHERITANCE",
"SYSTEM_ROLE_MUTATION",
"ADAPTER_ERROR",
"CACHE_ERROR",
] as const);
export type PermissionsErrorCode = (typeof PERM_CODES)[keyof typeof PERM_CODES];
/** Base error class for all permissions-package errors. */
export class PermissionsError extends SuaveplanError<PermissionsErrorCode> {}
/** Thrown when a role with the given name or ID cannot be found. */
export class RoleNotFoundError extends PermissionsError {
readonly roleId: string;
constructor(roleId: string) {
super(PERM_CODES.ROLE_NOT_FOUND, `Role "${roleId}" not found`, {
context: { roleId },
});
this.roleId = roleId;
}
}
/** Thrown when a user is denied access to a resource/action. */
export class PermissionDeniedError extends PermissionsError {
constructor(opts: { message?: string; userId?: string } = {}) {
super(
PERM_CODES.PERMISSION_NOT_FOUND,
opts.message ?? "Permission denied",
{
context:
opts.userId !== undefined ? { userId: opts.userId } : {},
}
);
}
}
/** Thrown when a permission with the given ID cannot be found. */
export class PermissionNotFoundError extends PermissionsError {
readonly permissionId: string;
constructor(permissionId: string) {
super(
PERM_CODES.PERMISSION_NOT_FOUND,
`Permission "${permissionId}" not found`,
{ context: { permissionId } }
);
this.permissionId = permissionId;
}
}
/** Thrown when a user-role assignment cannot be found. */
export class AssignmentNotFoundError extends PermissionsError {
constructor(opts: { userId: string; roleId: string }) {
super(
PERM_CODES.ASSIGNMENT_NOT_FOUND,
`No assignment found for user "${opts.userId}" with role "${opts.roleId}"`,
{ context: opts }
);
}
}
/** Thrown when a circular inheritance chain is detected in role definitions. */
export class CircularInheritanceError extends PermissionsError {
readonly chain: string[];
constructor(chain: string[]) {
super(
PERM_CODES.CIRCULAR_INHERITANCE,
`Circular role inheritance detected: ${chain.join(" → ")}`,
{ context: { chain } }
);
this.chain = chain;
}
}
/** Thrown when a caller attempts to mutate a system-protected role. */
export class SystemRoleMutationError extends PermissionsError {
readonly roleId: string;
constructor(roleId: string) {
super(
PERM_CODES.SYSTEM_ROLE_MUTATION,
`Cannot mutate system role "${roleId}"`,
{ context: { roleId } }
);
this.roleId = roleId;
}
}
/** Thrown when the underlying storage adapter encounters an error. */
export class PermissionsAdapterError extends PermissionsError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
PERM_CODES.ADAPTER_ERROR,
opts.message ?? "Permissions adapter error",
{ cause: opts.cause instanceof Error ? opts.cause : undefined }
);
}
}
/** Thrown when the permission cache encounters an unexpected error. */
export class PermissionsCacheError extends PermissionsError {
constructor(opts: { message?: string; cause?: unknown } = {}) {
super(
PERM_CODES.CACHE_ERROR,
opts.message ?? "Permission cache error",
{
cause:
opts.cause instanceof Error
? opts.cause
: /* istanbul ignore next */ undefined,
}
);
}
}

View File

@@ -0,0 +1,61 @@
# permission-events
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `PermissionEventMap` interface — the typed event contract emitted by `PermissionManager` and `RoleManager` on their optional `EventBusLike`. This file provides a single source of truth for event payloads, enabling consumers to build reactive architectures (audit logging, cache invalidation, notifications) by subscribing to permission lifecycle events.
The event map is intentionally kept as a pure type definition without runtime code so it can be imported by any consumer with zero runtime cost.
## Features
- `permission:grant` — emitted on `RoleManager.assignRole` with `userId`, `roleId`, optional `siteId`
- `permission:revoke` — emitted on `RoleManager.revokeRole` with same payload
- `permission:check` — emitted on `PermissionManager.hasPermission` with `userId`, `resource`, `action`, `allowed`
## Usage: Basic Example
```ts
import type { PermissionEventMap } from "@suaveplan/permissions";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<PermissionEventMap>();
bus.on("permission:grant", (payload) => {
console.log(`Role ${payload.roleId} granted to ${payload.userId}`);
});
```
## Usage: Advanced Example
```ts
import type { PermissionEventMap } from "@suaveplan/permissions";
function buildAuditLogger(bus: EventBusLike): void {
bus.on?.("permission:check", (e) => {
logger.info("permission:check", {
userId: e.userId,
resource: e.resource,
action: e.action,
allowed: e.allowed,
});
});
}
```
## API Reference
### `PermissionEventMap`
TypeScript interface extending `Record<string, unknown>` for `EventBusLike` compatibility.
Events:
- `"permission:grant"` → `{ userId, roleId, siteId? }`
- `"permission:revoke"` → `{ userId, roleId, siteId? }`
- `"permission:check"` → `{ userId, resource, action, allowed }`
## Implementation Notes
All payloads use `readonly` properties to prevent accidental mutation after emission. The `siteId` field on grant/revoke events is optional to support both single-tenant and multi-tenant deployments without requiring a default value.

View File

@@ -0,0 +1,24 @@
/**
* Event map for permission lifecycle events emitted by `PermissionManager`
* and `RoleManager`.
*
* Compatible with `EventBusLike` from `@suaveplan/types`.
*/
export interface PermissionEventMap {
"permission:grant": {
readonly userId: string;
readonly roleId: string;
readonly siteId?: string;
};
"permission:revoke": {
readonly userId: string;
readonly roleId: string;
readonly siteId?: string;
};
"permission:check": {
readonly userId: string;
readonly resource: string;
readonly action: string;
readonly allowed: boolean;
};
}

View File

@@ -0,0 +1,116 @@
// Errors
// Adapters
export type { GenericPermissionAdapter } from "./adapters/adapter-interface/adapter-interface.js";
export { MemoryAdapter } from "./adapters/memory/memory.js";
// Cache
export {
PermissionCache,
type PermissionCacheOptions,
} from "./cache/permission-cache/permission-cache.js";
// Conditions
export {
type ConditionMatcher,
compareValues,
DefaultConditionMatcher,
} from "./conditions/condition-matcher/condition-matcher.js";
export {
DefaultFieldMatcher,
type FieldMatcher,
fieldMatches,
} from "./conditions/field-matcher/field-matcher.js";
export {
DefaultSubjectDetector,
type SubjectDetector,
} from "./conditions/subject-detector/subject-detector.js";
// Constants
export {
CRUD_ACTIONS,
SYSTEM_ROLE_ADMIN,
SYSTEM_ROLE_EDITOR,
SYSTEM_ROLE_OWNER,
SYSTEM_ROLE_VIEWER,
WILDCARD_ACTION,
} from "./constants/index.js";
// Core
export {
Ability,
AbilityBuilder,
buildAbility,
} from "./core/ability/ability.js";
export { PermissionManager } from "./core/permission-manager/permission-manager.js";
export {
checkPermission as checkPolicyPermission,
PolicyEngine,
} from "./core/policy-engine/policy-engine.js";
export { RoleManager } from "./core/role-manager/role-manager.js";
export { Rule } from "./core/rule/rule.js";
export {
AssignmentNotFoundError,
CircularInheritanceError,
PERM_CODES,
PermissionNotFoundError,
PermissionsAdapterError,
PermissionsCacheError,
PermissionsError,
type PermissionsErrorCode,
RoleNotFoundError,
SystemRoleMutationError,
} from "./errors/permission-error.js";
// Events
export type { PermissionEventMap } from "./events/permission-events.js";
// Middleware
export {
checkPermission,
createPermissionMiddleware,
type ResourceExtractor as MiddlewareResourceExtractor,
type UserExtractor as MiddlewareUserExtractor,
} from "./middleware/permission-middleware/permission-middleware.js";
// Presets
export {
CRUD_PERMISSIONS,
createAdminRole,
createEditorRole,
createOwnerRole,
createViewerRole,
} from "./presets/role-presets/role-presets.js";
// Schemas
export {
ConditionOperatorSchema,
PermissionActionSchema,
PermissionCacheOptionsSchema,
PermissionConditionSchema,
type PermissionInput,
PermissionManagerOptionsSchema,
PermissionSchema,
PolicyEngineOptionsSchema,
type RoleInput,
RoleManagerOptionsSchema,
RoleSchema,
type UserRoleInput,
UserRoleSchema,
} from "./schemas/permissions.schemas.js";
// Types
export type {
AbilityRule,
ConditionOperator,
MiddlewareFn,
Permission,
PermissionAction,
PermissionCondition,
PermissionFilter,
PermissionManagerOptions,
PermissionMiddlewareOptions,
PolicyEngineOptions,
PolicyRequest,
PolicyResult,
ResourceExtractor,
Role,
RoleAssignOptions,
RoleFilter,
RoleManagerOptions,
UserExtractor,
UserRole,
} from "./types/permissions.types.js";

View File

@@ -0,0 +1,6 @@
export {
checkPermission,
createPermissionMiddleware,
type ResourceExtractor,
type UserExtractor,
} from "./permission-middleware.js";

View File

@@ -0,0 +1,71 @@
# permission-middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides framework-agnostic permission checking middleware for `@suaveplan/permissions`. The `createPermissionMiddleware` factory returns a `(req, res, next)` function that reads the authenticated user from `req.user`, checks the configured permission via the adapter, and calls `next(PermissionsError)` on denial.
The `checkPermission` function provides a one-off convenience check without creating a full engine instance, useful for route handlers that need a single inline permission assertion.
## Features
- `createPermissionMiddleware(options)` — factory returning `MiddlewareFn`
- `checkPermission(userId, resource, action, adapter)` — one-off policy evaluation
- User extraction from `req.user.userId` or `req.user.sub` (JWT-compatible)
- `ResourceExtractor` and `UserExtractor` type exports
## Usage: Basic Example
```ts
import { createPermissionMiddleware, MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
const mw = createPermissionMiddleware({
resource: "posts",
action: "delete",
adapter,
});
// Attach to route: app.delete("/posts/:id", verifyJwt, mw, handler)
```
## Usage: Advanced Example
```ts
import { checkPermission } from "@suaveplan/permissions";
import { MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
// Route handler inline check
async function deletePost(userId: string, postId: string) {
const result = await checkPermission(userId, "posts", "delete", adapter);
if (!result.allowed) throw new Error(result.reason);
// ... proceed with deletion
}
```
## API Reference
### `createPermissionMiddleware(options): MiddlewareFn`
Options: `resource`, `action`, `adapter?`.
Reads user from `req.user.userId` or `req.user.sub`. Calls `next(PermissionsError)` if no user, no adapter, or permission denied.
### `checkPermission(userId, resource, action, adapter): Promise<PolicyResult>`
Creates a transient `PolicyEngine` and evaluates one permission check.
### `ResourceExtractor`
`(req: unknown) => string` — type alias.
### `UserExtractor`
`(req: unknown) => string | null` — type alias.
## Implementation Notes
The middleware user extraction checks both `userId` (from a `UserContext`) and `sub` (from a `JWTPayload`) to support both `@suaveplan/auth`'s `verifyTokenMiddleware` (which attaches `req.user` as `JWTPayload`) and custom middleware that attaches `req.user` as `UserContext`. No adapter means the middleware fails closed with a `PERM_CODES.ADAPTER_ERROR`, preventing accidental open access when misconfigured.

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAdapter } from "../../adapters/memory/memory.js";
import { PermissionsError } from "../../errors/permission-error.js";
import type { Permission, Role } from "../../types/permissions.types.js";
import {
checkPermission,
createPermissionMiddleware,
} from "./permission-middleware.js";
function makePerm(id: string, resource = "posts", action = "read"): Permission {
return { id, resource, action };
}
async function setupAdapter(
userId: string,
roleId: string,
perms: Permission[]
): Promise<MemoryAdapter> {
const adapter = new MemoryAdapter();
const role: Role = { id: roleId, name: roleId, permissions: perms };
await adapter.createRole(role);
await adapter.assignRole({
id: `ur-${userId}`,
userId,
roleId,
assignedAt: new Date(),
});
return adapter;
}
describe("checkPermission", () => {
it("returns allowed:true for user with permission", {
timeout: 5000,
}, async () => {
const adapter = await setupAdapter("u1", "r1", [makePerm("p1")]);
const result = await checkPermission("u1", "posts", "read", adapter);
expect(result.allowed).toBe(true);
});
it("returns allowed:false for user without permission", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const result = await checkPermission(
"u-none",
"posts",
"read",
adapter
);
expect(result.allowed).toBe(false);
});
});
describe("createPermissionMiddleware", () => {
it("calls next() when user has permission", { timeout: 5000 }, async () => {
const adapter = await setupAdapter("u1", "r1", [makePerm("p1")]);
const mw = createPermissionMiddleware({
resource: "posts",
action: "read",
adapter,
});
const req = { user: { userId: "u1" } };
const errors: unknown[] = [];
await mw(req, {}, (e?: unknown) => {
if (e !== undefined) errors.push(e);
});
expect(errors).toHaveLength(0);
});
it("calls next(PermissionsError) when user lacks permission", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const mw = createPermissionMiddleware({
resource: "posts",
action: "delete",
adapter,
});
const req = { user: { userId: "u-none" } };
const errors: unknown[] = [];
await mw(req, {}, (e?: unknown) => {
if (e !== undefined) errors.push(e);
});
expect(errors[0]).toBeInstanceOf(PermissionsError);
});
it("extracts userId from sub (JWT-style)", { timeout: 5000 }, async () => {
const adapter = await setupAdapter("jwt-user", "r1", [makePerm("p1")]);
const mw = createPermissionMiddleware({
resource: "posts",
action: "read",
adapter,
});
const req = { user: { sub: "jwt-user" } };
const errors: unknown[] = [];
await mw(req, {}, (e?: unknown) => {
if (e !== undefined) errors.push(e);
});
expect(errors).toHaveLength(0);
});
it("calls next(PermissionsError) when no user present", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
const mw = createPermissionMiddleware({
resource: "posts",
action: "read",
adapter,
});
const req = {};
const errors: unknown[] = [];
await mw(req, {}, (e?: unknown) => {
if (e !== undefined) errors.push(e);
});
expect(errors[0]).toBeInstanceOf(PermissionsError);
});
it("calls next(PermissionsError) when no adapter configured", {
timeout: 5000,
}, async () => {
const mw = createPermissionMiddleware({
resource: "posts",
action: "read",
});
const req = { user: { userId: "u1" } };
const errors: unknown[] = [];
await mw(req, {}, (e?: unknown) => {
if (e !== undefined) errors.push(e);
});
expect(errors[0]).toBeInstanceOf(PermissionsError);
});
it("propagates thrown errors via catch block", {
timeout: 5000,
}, async () => {
const faultyAdapter = {
...new MemoryAdapter(),
getUserRoles: async () => {
throw new Error("storage failure");
},
} as InstanceType<typeof MemoryAdapter>;
const mw = createPermissionMiddleware({
resource: "posts",
action: "read",
adapter: faultyAdapter,
});
const req = { user: { userId: "u1" } };
const errors: unknown[] = [];
await mw(req, {}, (e?: unknown) => {
if (e !== undefined) errors.push(e);
});
expect(errors[0]).toBeInstanceOf(Error);
});
});

View File

@@ -0,0 +1,94 @@
import { PermissionManager } from "../../core/permission-manager/permission-manager.js";
import { PolicyEngine } from "../../core/policy-engine/policy-engine.js";
import {
PermissionDeniedError,
PermissionsAdapterError,
} from "../../errors/permission-error.js";
import type {
MiddlewareFn,
PermissionMiddlewareOptions,
PolicyResult,
ResourceExtractor,
UserExtractor,
} from "../../types/permissions.types.js";
/**
* Convenience function for a one-off permission check.
*
* Uses a transient `PolicyEngine` without caching.
*/
export async function checkPermission(
userId: string,
resource: string,
action: string,
adapter: import("../../adapters/adapter-interface/adapter-interface.js").GenericPermissionAdapter
): Promise<PolicyResult> {
const manager = new PermissionManager(adapter);
const engine = new PolicyEngine(manager);
return engine.evaluate(userId, resource, action);
}
/**
* Factory for a permission-checking middleware.
*
* Reads `userId` from `req.user.userId` or `req.user.sub` and checks whether
* the user has the required permission via the adapter. Calls `next(error)` on
* denial.
*/
export function createPermissionMiddleware(
options: PermissionMiddlewareOptions
): MiddlewareFn {
return async (req, _res, next) => {
try {
const r = req as Record<string, unknown>;
const user = r["user"] as Record<string, unknown> | undefined;
const userId =
(typeof user?.["userId"] === "string"
? user["userId"]
: undefined) ??
(typeof user?.["sub"] === "string" ? user["sub"] : undefined);
if (!userId) {
next(
new PermissionDeniedError({
message: "No authenticated user found in request",
})
);
return;
}
if (!options.adapter) {
next(
new PermissionsAdapterError({
message: "No permissions adapter configured",
})
);
return;
}
const result = await checkPermission(
userId,
options.resource,
options.action,
options.adapter
);
if (!result.allowed) {
next(
new PermissionDeniedError({
message:
result.reason ??
/* istanbul ignore next */ "Permission denied",
})
);
return;
}
next();
} catch (err) {
next(err);
}
};
}
export type { ResourceExtractor, UserExtractor };

View File

@@ -0,0 +1,7 @@
export {
CRUD_PERMISSIONS,
createAdminRole,
createEditorRole,
createOwnerRole,
createViewerRole,
} from "./role-presets.js";

View File

@@ -0,0 +1,65 @@
# role-presets
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides pre-built role factories and permission bundles for the most common access-control patterns in `@suaveplan/permissions`. Rather than requiring every application to redefine the same admin/viewer/editor roles from scratch, the presets offer sensible defaults that can be used directly or overridden with a custom permissions array.
## Features
- `createAdminRole` — full wildcard `*:*` permissions, system-protected
- `createViewerRole` — `read` and `list` across all resources
- `createEditorRole` — `read`, `list`, `create`, `update` across all resources
- `createOwnerRole` — full wildcard permissions (like admin but semantically for resource owners)
- `CRUD_PERMISSIONS` — pre-built permission sets for `posts` and `users` resources
## Usage: Basic Example
```ts
import { createAdminRole, createViewerRole } from "@suaveplan/permissions";
import { MemoryAdapter } from "@suaveplan/permissions";
const adapter = new MemoryAdapter();
await adapter.createRole(createAdminRole());
await adapter.createRole(createViewerRole());
```
## Usage: Advanced Example
```ts
import { createEditorRole } from "@suaveplan/permissions";
// Override permissions
const customEditor = createEditorRole([
{ id: "editor:posts:write", resource: "posts", action: "create" },
{ id: "editor:posts:read", resource: "posts", action: "read" },
]);
```
## API Reference
### `createAdminRole(permissions?): Role`
Returns a system role with `id: "admin"` and `isSystem: true`. Default permissions: `{ resource: "*", action: "*" }`.
### `createViewerRole(permissions?): Role`
Returns a system role with `id: "viewer"`. Default: `read` and `list` on all resources.
### `createEditorRole(permissions?): Role`
Returns a system role with `id: "editor"`. Default: `read`, `list`, `create`, `update` on all resources.
### `createOwnerRole(permissions?): Role`
Returns a system role with `id: "owner"`. Default: wildcard on all resources.
### `CRUD_PERMISSIONS`
`Record<string, Permission[]>` — pre-built permission sets for `posts` and `users` resources.
## Implementation Notes
All preset roles have `isSystem: true` set, which means `RoleManager.updateRole` and `RoleManager.deleteRole` will throw `SystemRoleMutationError` to prevent accidental modification. If you need to customise a system role, either pass a custom `permissions` array to the factory, or create a new non-system role and assign it alongside the preset.

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CRUD_PERMISSIONS,
createAdminRole,
createEditorRole,
createOwnerRole,
createViewerRole,
} from "./role-presets.js";
describe("createAdminRole", () => {
it("has correct id and isSystem", { timeout: 5000 }, () => {
const role = createAdminRole();
expect(role.id).toBe("admin");
expect(role.isSystem).toBe(true);
});
it("has wildcard permission by default", { timeout: 5000 }, () => {
const role = createAdminRole();
expect(role.permissions[0]?.action).toBe("*");
});
it("accepts custom permissions", { timeout: 5000 }, () => {
const custom = [{ id: "x", resource: "r", action: "read" }];
const role = createAdminRole(custom);
expect(role.permissions).toEqual(custom);
});
});
describe("createViewerRole", () => {
it("has correct id", { timeout: 5000 }, () => {
expect(createViewerRole().id).toBe("viewer");
});
it("has read and list permissions", { timeout: 5000 }, () => {
const role = createViewerRole();
const actions = role.permissions.map((p) => p.action);
expect(actions).toContain("read");
expect(actions).toContain("list");
});
});
describe("createEditorRole", () => {
it("has correct id", { timeout: 5000 }, () => {
expect(createEditorRole().id).toBe("editor");
});
it("includes create and update", { timeout: 5000 }, () => {
const role = createEditorRole();
const actions = role.permissions.map((p) => p.action);
expect(actions).toContain("create");
expect(actions).toContain("update");
});
});
describe("createOwnerRole", () => {
it("has correct id", { timeout: 5000 }, () => {
expect(createOwnerRole().id).toBe("owner");
});
it("has wildcard permission", { timeout: 5000 }, () => {
const role = createOwnerRole();
expect(role.permissions[0]?.action).toBe("*");
});
});
describe("CRUD_PERMISSIONS", () => {
it("has posts permissions", { timeout: 5000 }, () => {
expect(CRUD_PERMISSIONS["posts"]).toBeDefined();
expect(CRUD_PERMISSIONS["posts"]).toHaveLength(5);
});
it("has users permissions", { timeout: 5000 }, () => {
expect(CRUD_PERMISSIONS["users"]).toBeDefined();
expect(CRUD_PERMISSIONS["users"]).toHaveLength(5);
});
});

View File

@@ -0,0 +1,80 @@
import type { Permission, Role } from "../../types/permissions.types.js";
/** Default CRUD permissions for common resources. */
export const CRUD_PERMISSIONS: Record<string, Permission[]> = {
posts: [
{ id: "posts:create", resource: "posts", action: "create" },
{ id: "posts:read", resource: "posts", action: "read" },
{ id: "posts:update", resource: "posts", action: "update" },
{ id: "posts:delete", resource: "posts", action: "delete" },
{ id: "posts:list", resource: "posts", action: "list" },
],
users: [
{ id: "users:create", resource: "users", action: "create" },
{ id: "users:read", resource: "users", action: "read" },
{ id: "users:update", resource: "users", action: "update" },
{ id: "users:delete", resource: "users", action: "delete" },
{ id: "users:list", resource: "users", action: "list" },
],
};
/**
* Create a pre-built admin role with full wildcard permissions.
*/
export function createAdminRole(permissions?: Permission[]): Role {
return {
id: "admin",
name: "Admin",
isSystem: true,
permissions: permissions ?? [
{ id: "admin:*", resource: "*", action: "*" },
],
};
}
/**
* Create a pre-built viewer role with read-only permissions.
*/
export function createViewerRole(permissions?: Permission[]): Role {
return {
id: "viewer",
name: "Viewer",
isSystem: true,
permissions: permissions ?? [
{ id: "viewer:read", resource: "*", action: "read" },
{ id: "viewer:list", resource: "*", action: "list" },
],
};
}
/**
* Create a pre-built editor role with create, read, update, and list
* permissions.
*/
export function createEditorRole(permissions?: Permission[]): Role {
return {
id: "editor",
name: "Editor",
isSystem: true,
permissions: permissions ?? [
{ id: "editor:read", resource: "*", action: "read" },
{ id: "editor:list", resource: "*", action: "list" },
{ id: "editor:create", resource: "*", action: "create" },
{ id: "editor:update", resource: "*", action: "update" },
],
};
}
/**
* Create a pre-built owner role with full permissions including delete.
*/
export function createOwnerRole(permissions?: Permission[]): Role {
return {
id: "owner",
name: "Owner",
isSystem: true,
permissions: permissions ?? [
{ id: "owner:*", resource: "*", action: "*" },
],
};
}

View File

@@ -0,0 +1,15 @@
export {
ConditionOperatorSchema,
PermissionActionSchema,
PermissionCacheOptionsSchema,
PermissionConditionSchema,
type PermissionInput,
PermissionManagerOptionsSchema,
PermissionSchema,
PolicyEngineOptionsSchema,
type RoleInput,
RoleManagerOptionsSchema,
RoleSchema,
type UserRoleInput,
UserRoleSchema,
} from "./permissions.schemas.js";

View File

@@ -0,0 +1,61 @@
# permissions.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod validation schemas for all public domain types and option bundles in `@suaveplan/permissions`. These schemas enable runtime validation of permissions data at system boundaries — configuration files, network payloads, database reads — and provide structured field-level error messages compatible with `@suaveplan/error`.
## Features
- `PermissionConditionSchema` — validates condition operator and field name
- `PermissionSchema` — validates full permission record including optional conditions
- `RoleSchema` — validates role with permissions array and optional inheritance
- `UserRoleSchema` — validates user-role assignment with Date fields
- Option schemas for `PermissionManager`, `RoleManager`, `PolicyEngine`, `PermissionCache`
- Exported inferred TypeScript types
## Usage: Basic Example
```ts
import { PermissionSchema } from "@suaveplan/permissions";
const result = PermissionSchema.safeParse({
id: "p1",
resource: "posts",
action: "read",
});
if (!result.success) console.error(result.error.flatten());
```
## Usage: Advanced Example
```ts
import { RoleSchema } from "@suaveplan/permissions";
const role = RoleSchema.parse({
id: "editor",
name: "Editor",
permissions: [{ id: "p1", resource: "posts", action: "write" }],
inherits: ["viewer"],
});
```
## API Reference
### `ConditionOperatorSchema` — enum of 12 operators
### `PermissionConditionSchema` — `{ field, operator, value }`
### `PermissionSchema` — `{ id, resource, action, conditions?, description? }`
### `RoleSchema` — `{ id, name, permissions, inherits?, metadata?, isSystem?, siteId? }`
### `UserRoleSchema` — `{ id, userId, roleId, siteId?, assignedAt: Date, expiresAt?: Date, assignedBy? }`
### `PermissionCacheOptionsSchema` — `{ ttl, maxSize? }`
## Implementation Notes
The `PermissionActionSchema` uses a union of the canonical enum and a general `z.string().min(1)` to allow custom action strings beyond the predefined set. Option schemas use `z.unknown()` for injectable dependencies (event bus, cache) to avoid circular Zod schema references.

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PermissionCacheOptionsSchema,
PermissionConditionSchema,
PermissionSchema,
RoleSchema,
UserRoleSchema,
} from "./permissions.schemas.js";
describe("PermissionConditionSchema", () => {
it("accepts valid condition", { timeout: 5000 }, () => {
expect(
PermissionConditionSchema.safeParse({
field: "user.role",
operator: "eq",
value: "admin",
}).success
).toBe(true);
});
it("rejects invalid operator", { timeout: 5000 }, () => {
expect(
PermissionConditionSchema.safeParse({
field: "x",
operator: "like",
value: "y",
}).success
).toBe(false);
});
});
describe("PermissionSchema", () => {
it("accepts valid permission", { timeout: 5000 }, () => {
expect(
PermissionSchema.safeParse({
id: "p1",
resource: "posts",
action: "read",
}).success
).toBe(true);
});
it("rejects empty id", { timeout: 5000 }, () => {
expect(
PermissionSchema.safeParse({
id: "",
resource: "posts",
action: "read",
}).success
).toBe(false);
});
it("accepts custom action string", { timeout: 5000 }, () => {
expect(
PermissionSchema.safeParse({
id: "p1",
resource: "posts",
action: "approve",
}).success
).toBe(true);
});
});
describe("RoleSchema", () => {
it("accepts valid role", { timeout: 5000 }, () => {
expect(
RoleSchema.safeParse({
id: "r1",
name: "Viewer",
permissions: [],
}).success
).toBe(true);
});
it("rejects missing name", { timeout: 5000 }, () => {
expect(
RoleSchema.safeParse({ id: "r1", permissions: [] }).success
).toBe(false);
});
});
describe("UserRoleSchema", () => {
it("accepts valid user role", { timeout: 5000 }, () => {
expect(
UserRoleSchema.safeParse({
id: "ur1",
userId: "u1",
roleId: "r1",
assignedAt: new Date(),
}).success
).toBe(true);
});
it("rejects missing assignedAt", { timeout: 5000 }, () => {
expect(
UserRoleSchema.safeParse({ id: "ur1", userId: "u1", roleId: "r1" })
.success
).toBe(false);
});
});
describe("PermissionCacheOptionsSchema", () => {
it("accepts valid config", { timeout: 5000 }, () => {
expect(
PermissionCacheOptionsSchema.safeParse({ ttl: 60000 }).success
).toBe(true);
});
it("rejects zero ttl", { timeout: 5000 }, () => {
expect(PermissionCacheOptionsSchema.safeParse({ ttl: 0 }).success).toBe(
false
);
});
});

View File

@@ -0,0 +1,76 @@
import z from "zod";
export const ConditionOperatorSchema = z.enum([
"eq",
"neq",
"gt",
"gte",
"lt",
"lte",
"in",
"nin",
"contains",
"startsWith",
"endsWith",
"matches",
]);
export const PermissionConditionSchema = z.object({
field: z.string().min(1),
operator: ConditionOperatorSchema,
value: z.unknown(),
});
export const PermissionActionSchema = z.union([
z.enum(["create", "read", "update", "delete", "list", "manage", "*"]),
z.string().min(1),
]);
export const PermissionSchema = z.object({
id: z.string().min(1),
resource: z.string().min(1),
action: PermissionActionSchema,
conditions: z.array(PermissionConditionSchema).optional(),
description: z.string().optional(),
});
export const RoleSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
permissions: z.array(PermissionSchema),
inherits: z.array(z.string()).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
isSystem: z.boolean().optional(),
siteId: z.string().optional(),
});
export const UserRoleSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
roleId: z.string().min(1),
siteId: z.string().optional(),
assignedAt: z.date(),
expiresAt: z.date().optional(),
assignedBy: z.string().optional(),
});
export const PermissionManagerOptionsSchema = z
.object({ eventBus: z.unknown().optional() })
.optional();
export const RoleManagerOptionsSchema = z
.object({ eventBus: z.unknown().optional() })
.optional();
export const PolicyEngineOptionsSchema = z
.object({ cache: z.unknown().optional() })
.optional();
export const PermissionCacheOptionsSchema = z.object({
ttl: z.number().int().positive(),
maxSize: z.number().int().positive().optional(),
});
export type PermissionInput = z.infer<typeof PermissionSchema>;
export type RoleInput = z.infer<typeof RoleSchema>;
export type UserRoleInput = z.infer<typeof UserRoleSchema>;

View File

@@ -0,0 +1,21 @@
export type {
AbilityRule,
ConditionOperator,
MiddlewareFn,
Permission,
PermissionAction,
PermissionCondition,
PermissionFilter,
PermissionManagerOptions,
PermissionMiddlewareOptions,
PolicyEngineOptions,
PolicyRequest,
PolicyResult,
ResourceExtractor,
Role,
RoleAssignOptions,
RoleFilter,
RoleManagerOptions,
UserExtractor,
UserRole,
} from "./permissions.types.js";

View File

@@ -0,0 +1,103 @@
# permissions.types
## Purpose
Central type definitions for `@suaveplan/permissions`. All shared interfaces, type aliases, and option bundles live here so consumers can import lightweight type-only shapes without pulling in implementation code. The types are intentionally framework-agnostic and carry no runtime dependencies — they can be consumed in both Node.js and browser environments.
## Features
- `PermissionAction` union type (`create | read | update | delete | list | manage | *`)
- `ConditionOperator` union type (12 comparison operators)
- `Permission` / `Role` / `UserRole` — core domain objects with `readonly` modifiers
- `PermissionCondition` — field/operator/value tuple for ABAC conditions
- `AbilityRule` — rule shape emitted by `AbilityBuilder`
- `PolicyResult` — discriminated result from `PolicyEngine.evaluate`
- `PolicyRequest` — input shape for `PolicyEngine.evaluateMany`
- Option bundles: `PermissionManagerOptions`, `RoleManagerOptions`, `PolicyEngineOptions`
- `MiddlewareFn`, `ResourceExtractor`, `UserExtractor` — middleware composition types
- `PermissionMiddlewareOptions` — configuration for `createPermissionMiddleware`
## Usage: Basic Example
```ts
import type { Permission, Role, UserRole } from "@suaveplan/permissions";
const readPerm: Permission = {
id: "p-read-posts",
resource: "posts",
action: "read",
};
const readerRole: Role = {
id: "r-reader",
name: "Reader",
permissions: [readPerm],
};
```
## Usage: Advanced Example
```ts
import type {
PolicyRequest,
PolicyResult,
PermissionCondition,
ConditionOperator,
} from "@suaveplan/permissions";
const condition: PermissionCondition = {
field: "post.authorId",
operator: "eq",
value: "${userId}",
};
const requests: PolicyRequest[] = [
{ userId: "u1", resource: "posts", action: "delete", context: { "post.authorId": "u1" } },
];
```
## API Reference
### `PermissionAction`
`"create" | "read" | "update" | "delete" | "list" | "manage" | "*"`.
### `ConditionOperator`
`"eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" | "nin" | "contains" | "startsWith" | "endsWith" | "matches"`.
### `Permission`
`{ id, resource, action, conditions?, description? }` — all `readonly`.
### `Role`
`{ id, name, permissions, inherits?, metadata?, isSystem?, siteId? }` — all `readonly`.
### `UserRole`
`{ id, userId, roleId, siteId?, assignedAt: Date, expiresAt?: Date, assignedBy? }`.
### `AbilityRule`
`{ action, resource, conditions?, inverted: boolean }` — produced by `AbilityBuilder`.
### `PolicyResult`
`{ allowed: boolean, matchedRule?, reason?, conditions? }`.
### `PolicyRequest`
`{ userId, resource, action, context? }` — input to `evaluateMany`.
### `PermissionManagerOptions` / `RoleManagerOptions`
`{ eventBus?: EventBusLike }` — optional event bus for lifecycle events.
### `PolicyEngineOptions`
`{ cache?: PermissionCache }` — optional cache layer.
## Implementation Notes
All interfaces use `readonly` modifiers on properties that should not be mutated after creation. The `isSystem` flag on `Role` is used by `RoleManager` to prevent accidental deletion or modification of built-in roles (admin, viewer, etc.) that are seeded at startup. `UserRole.expiresAt` enables time-limited role assignments for scenarios like guest access or temporary elevated privileges.

View File

@@ -0,0 +1,140 @@
/** Canonical permission actions. `*` means all actions. */
export type PermissionAction =
| "create"
| "read"
| "update"
| "delete"
| "list"
| "manage"
| "*";
/** Operators supported by `PermissionCondition`. */
export type ConditionOperator =
| "eq"
| "neq"
| "gt"
| "gte"
| "lt"
| "lte"
| "in"
| "nin"
| "contains"
| "startsWith"
| "endsWith"
| "matches";
/** A single conditional predicate attached to a permission. */
export interface PermissionCondition {
readonly field: string;
readonly operator: ConditionOperator;
readonly value: unknown;
}
/** A named permission granting an action on a resource under optional conditions. */
export interface Permission {
readonly id: string;
readonly resource: string;
readonly action: PermissionAction | string;
readonly conditions?: PermissionCondition[];
readonly description?: string;
}
/** A role composed of permissions with optional inheritance from other roles. */
export interface Role {
readonly id: string;
readonly name: string;
readonly permissions: Permission[];
readonly inherits?: string[];
readonly metadata?: Record<string, unknown>;
readonly isSystem?: boolean;
readonly siteId?: string;
}
/** Mapping of a user to a role with temporal bounds. */
export interface UserRole {
readonly id: string;
readonly userId: string;
readonly roleId: string;
readonly siteId?: string;
readonly assignedAt: Date;
readonly expiresAt?: Date;
readonly assignedBy?: string;
}
/** Options for assigning a role to a user. */
export interface RoleAssignOptions {
readonly siteId?: string;
readonly expiresAt?: Date;
readonly assignedBy?: string;
}
/** Filter shape for permission queries. */
export interface PermissionFilter {
readonly resource?: string;
readonly action?: string;
}
/** Filter shape for role queries. */
export interface RoleFilter {
readonly siteId?: string;
readonly isSystem?: boolean;
}
/** Request shape for `PolicyEngine.evaluateMany`. */
export interface PolicyRequest {
readonly userId: string;
readonly resource: string;
readonly action: string;
readonly context?: Record<string, unknown>;
}
/** Result returned by `PolicyEngine.evaluate`. */
export interface PolicyResult {
readonly allowed: boolean;
readonly matchedRule?: string;
readonly reason?: string;
readonly conditions?: PermissionCondition[];
}
/** An ability rule built by `AbilityBuilder`. */
export interface AbilityRule {
readonly action: PermissionAction | string;
readonly resource: string;
readonly conditions?: PermissionCondition[];
readonly inverted: boolean;
}
/** Options for `PermissionManager`. */
export interface PermissionManagerOptions {
readonly eventBus?: import("@suaveplan/types").EventBusLike;
}
/** Options for `RoleManager`. */
export interface RoleManagerOptions {
readonly eventBus?: import("@suaveplan/types").EventBusLike;
}
/** Options for `PolicyEngine`. */
export interface PolicyEngineOptions {
readonly cache?: import("../cache/permission-cache/permission-cache.js").PermissionCache;
}
/** Framework-agnostic middleware function signature. */
export type MiddlewareFn = (
req: unknown,
res: unknown,
next: (err?: unknown) => void
) => void | Promise<void>;
/** Extractor that derives a resource identifier from a request. */
export type ResourceExtractor = (req: unknown) => string;
/** Extractor that derives a user ID from a request. */
export type UserExtractor = (req: unknown) => string | null;
/** Options for `createPermissionMiddleware`. */
export interface PermissionMiddlewareOptions {
readonly resource: string;
readonly action: string;
readonly adapter?: import("../adapters/adapter-interface/adapter-interface.js").GenericPermissionAdapter;
}

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryAdapter } from "../src/adapters/memory/memory.js";
import { PermissionCache } from "../src/cache/permission-cache/permission-cache.js";
import { type Ability, buildAbility } from "../src/core/ability/ability.js";
import { PermissionManager } from "../src/core/permission-manager/permission-manager.js";
import { PolicyEngine } from "../src/core/policy-engine/policy-engine.js";
import { RoleManager } from "../src/core/role-manager/role-manager.js";
import { CircularInheritanceError } from "../src/errors/permission-error.js";
import {
createAdminRole,
createViewerRole,
} from "../src/presets/role-presets/role-presets.js";
import type { Permission } from "../src/types/permissions.types.js";
function makePerm(id: string, resource = "posts", action = "read"): Permission {
return { id, resource, action };
}
describe("Full permissions flow", () => {
it("role inheritance transitivity", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const roleManager = new RoleManager(adapter);
const _manager = new PermissionManager(adapter);
const cPerm = makePerm("p-c", "comments", "read");
const bPerm = makePerm("p-b", "posts", "write");
const aPerm = makePerm("p-a", "posts", "read");
await adapter.createRole({ id: "c", name: "C", permissions: [cPerm] });
await adapter.createRole({
id: "b",
name: "B",
permissions: [bPerm],
inherits: ["c"],
});
await adapter.createRole({
id: "a",
name: "A",
permissions: [aPerm],
inherits: ["b"],
});
const perms = await roleManager.getEffectivePermissions("a");
const permIds = perms.map((p) => p.id);
expect(permIds).toContain("p-a");
expect(permIds).toContain("p-b");
expect(permIds).toContain("p-c");
});
it("policy engine with cache", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole({
id: "r1",
name: "R",
permissions: [makePerm("p1")],
});
await adapter.assignRole({
id: "ur1",
userId: "u1",
roleId: "r1",
assignedAt: new Date(),
});
const manager = new PermissionManager(adapter);
const cache = new PermissionCache({ ttl: 60_000 });
const engine = new PolicyEngine(manager, { cache });
const r1 = await engine.evaluate("u1", "posts", "read");
const r2 = await engine.evaluate("u1", "posts", "read");
expect(r1.allowed).toBe(true);
expect(r2.matchedRule).toBe("cached-allow");
// Invalidate cache and re-check
cache.invalidate("u1");
const r3 = await engine.evaluate("u1", "posts", "read");
expect(r3.allowed).toBe(true);
expect(r3.matchedRule).toBe("posts:read");
});
it("circular inheritance detection", { timeout: 5000 }, async () => {
const adapter = new MemoryAdapter();
const roleManager = new RoleManager(adapter);
await adapter.createRole({
id: "x",
name: "X",
permissions: [],
inherits: ["y"],
});
await adapter.createRole({
id: "y",
name: "Y",
permissions: [],
inherits: ["x"],
});
await expect(
roleManager.getRoleInheritanceChain("x")
).rejects.toBeInstanceOf(CircularInheritanceError);
});
it("preset roles work with PermissionManager", {
timeout: 5000,
}, async () => {
const adapter = new MemoryAdapter();
await adapter.createRole(createAdminRole());
await adapter.assignRole({
id: "ur-admin",
userId: "admin-user",
roleId: "admin",
assignedAt: new Date(),
});
await adapter.createRole(createViewerRole());
await adapter.assignRole({
id: "ur-viewer",
userId: "viewer-user",
roleId: "viewer",
assignedAt: new Date(),
});
const manager = new PermissionManager(adapter);
expect(await manager.hasPermission("admin-user", "any", "any")).toBe(
true
);
expect(
await manager.hasPermission("viewer-user", "posts", "read")
).toBe(true);
expect(
await manager.hasPermission("viewer-user", "posts", "delete")
).toBe(false);
});
});
describe("Ability system end-to-end", () => {
it("can/cannot consistency", { timeout: 5000 }, () => {
const ability: Ability = buildAbility((b) => {
b.can("read", "posts");
b.cannot("delete", "posts");
});
expect(ability.can("read", "posts")).toBe(true);
expect(ability.cannot("read", "posts")).toBe(false);
expect(ability.can("delete", "posts")).toBe(false);
expect(ability.cannot("delete", "posts")).toBe(true);
});
});

View File

@@ -0,0 +1,109 @@
import { describe, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { PermissionCache } from "../src/cache/permission-cache/permission-cache.js";
import { compareValues } from "../src/conditions/condition-matcher/condition-matcher.js";
import { buildAbility } from "../src/core/ability/ability.js";
import type { ConditionOperator } from "../src/types/permissions.types.js";
describe("Property: Ability can/cannot consistency", () => {
it("cannot is always negation of can for any action/resource", {
timeout: 5000,
}, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 16 }),
fc.string({ minLength: 1, maxLength: 16 }),
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
minLength: 0,
maxLength: 5,
}),
(action, resource, grantedActions) => {
const ability = buildAbility((b) => {
for (const a of grantedActions) {
b.can(a, resource);
}
});
const canResult = ability.can(action, resource);
const cannotResult = ability.cannot(action, resource);
return canResult !== cannotResult;
}
),
{ numRuns: 200 }
);
});
});
describe("Property: Cache invalidation completeness", () => {
it("after invalidate(userId), no stale entry for that user", {
timeout: 5000,
}, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 16 }),
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
minLength: 1,
maxLength: 10,
}),
(userId, resources) => {
const cache = new PermissionCache({ ttl: 99999 });
for (const r of resources) {
cache.set(`${userId}:${r}:read`, true);
}
cache.invalidate(userId);
return resources.every(
(r) => cache.get(`${userId}:${r}:read`) === undefined
);
}
),
{ numRuns: 100 }
);
});
});
describe("Property: compareValues determinism", () => {
it("same inputs always produce same result", { timeout: 5000 }, () => {
const ops: ConditionOperator[] = [
"eq",
"neq",
"contains",
"startsWith",
];
fc.assert(
fc.property(
fc.string({ minLength: 0, maxLength: 32 }),
fc.string({ minLength: 0, maxLength: 32 }),
fc.constantFrom(...ops),
(a, b, op) => {
const r1 = compareValues(a, b, op);
const r2 = compareValues(a, b, op);
return r1 === r2;
}
),
{ numRuns: 300 }
);
});
});
describe("Property: Role inheritance transitivity via Ability", () => {
it("abilities compose additively across rule sets", {
timeout: 5000,
}, () => {
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1, maxLength: 8 }), {
minLength: 1,
maxLength: 5,
}),
fc.string({ minLength: 1, maxLength: 8 }),
(actions, resource) => {
const ability = buildAbility((b) => {
for (const a of actions) b.can(a, resource);
});
// All granted actions must return can=true
return actions.every((a) => ability.can(a, resource));
}
),
{ numRuns: 100 }
);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/permissions",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/events",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});