Initial Commit
This commit is contained in:
97
packages/identity/auth/permissions/README.md
Normal file
97
packages/identity/auth/permissions/README.md
Normal file
File diff suppressed because one or more lines are too long
106
packages/identity/auth/permissions/package.json
Normal file
106
packages/identity/auth/permissions/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type { GenericPermissionAdapter } from "./adapter-interface.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { MemoryAdapter } from "./memory.js";
|
||||
128
packages/identity/auth/permissions/src/adapters/memory/memory.md
Normal file
128
packages/identity/auth/permissions/src/adapters/memory/memory.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
158
packages/identity/auth/permissions/src/adapters/memory/memory.ts
Normal file
158
packages/identity/auth/permissions/src/adapters/memory/memory.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
4
packages/identity/auth/permissions/src/cache/permission-cache/index.ts
vendored
Normal file
4
packages/identity/auth/permissions/src/cache/permission-cache/index.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
PermissionCache,
|
||||
type PermissionCacheOptions,
|
||||
} from "./permission-cache.js";
|
||||
92
packages/identity/auth/permissions/src/cache/permission-cache/permission-cache.md
vendored
Normal file
92
packages/identity/auth/permissions/src/cache/permission-cache/permission-cache.md
vendored
Normal 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).
|
||||
95
packages/identity/auth/permissions/src/cache/permission-cache/permission-cache.test.ts
vendored
Normal file
95
packages/identity/auth/permissions/src/cache/permission-cache/permission-cache.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
79
packages/identity/auth/permissions/src/cache/permission-cache/permission-cache.ts
vendored
Normal file
79
packages/identity/auth/permissions/src/cache/permission-cache/permission-cache.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type ConditionMatcher,
|
||||
compareValues,
|
||||
DefaultConditionMatcher,
|
||||
} from "./condition-matcher.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
DefaultFieldMatcher,
|
||||
type FieldMatcher,
|
||||
fieldMatches,
|
||||
} from "./field-matcher.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DefaultSubjectDetector,
|
||||
type SubjectDetector,
|
||||
} from "./subject-detector.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
17
packages/identity/auth/permissions/src/constants/index.ts
Normal file
17
packages/identity/auth/permissions/src/constants/index.ts
Normal 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;
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Re-export AbilityBuilder from core ability module for subpath export compatibility
|
||||
export { AbilityBuilder, buildAbility } from "../ability/ability.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { AbilityBuilder, buildAbility } from "./ability-builder.js";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
122
packages/identity/auth/permissions/src/core/ability/ability.ts
Normal file
122
packages/identity/auth/permissions/src/core/ability/ability.ts
Normal 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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Ability, AbilityBuilder, buildAbility } from "./ability.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { PermissionManager } from "./permission-manager.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { checkPermission, PolicyEngine } from "./policy-engine.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { RoleManager } from "./role-manager.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Rule } from "./rule.js";
|
||||
73
packages/identity/auth/permissions/src/core/rule/rule.md
Normal file
73
packages/identity/auth/permissions/src/core/rule/rule.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
45
packages/identity/auth/permissions/src/core/rule/rule.ts
Normal file
45
packages/identity/auth/permissions/src/core/rule/rule.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
12
packages/identity/auth/permissions/src/errors/index.ts
Normal file
12
packages/identity/auth/permissions/src/errors/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export {
|
||||
AssignmentNotFoundError,
|
||||
CircularInheritanceError,
|
||||
PERM_CODES,
|
||||
PermissionNotFoundError,
|
||||
PermissionsAdapterError,
|
||||
PermissionsCacheError,
|
||||
PermissionsError,
|
||||
type PermissionsErrorCode,
|
||||
RoleNotFoundError,
|
||||
SystemRoleMutationError,
|
||||
} from "./permission-error.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
116
packages/identity/auth/permissions/src/index.ts
Normal file
116
packages/identity/auth/permissions/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
checkPermission,
|
||||
createPermissionMiddleware,
|
||||
type ResourceExtractor,
|
||||
type UserExtractor,
|
||||
} from "./permission-middleware.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
CRUD_PERMISSIONS,
|
||||
createAdminRole,
|
||||
createEditorRole,
|
||||
createOwnerRole,
|
||||
createViewerRole,
|
||||
} from "./role-presets.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: "*" },
|
||||
],
|
||||
};
|
||||
}
|
||||
15
packages/identity/auth/permissions/src/schemas/index.ts
Normal file
15
packages/identity/auth/permissions/src/schemas/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
21
packages/identity/auth/permissions/src/types/index.ts
Normal file
21
packages/identity/auth/permissions/src/types/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
146
packages/identity/auth/permissions/tests/integration.test.ts
Normal file
146
packages/identity/auth/permissions/tests/integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
109
packages/identity/auth/permissions/tests/property.test.ts
Normal file
109
packages/identity/auth/permissions/tests/property.test.ts
Normal 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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/identity/auth/permissions/tsconfig.json
Normal file
12
packages/identity/auth/permissions/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/identity/auth/permissions/tsconfig.lib.json
Normal file
12
packages/identity/auth/permissions/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/identity/auth/permissions/vite.config.ts
Normal file
38
packages/identity/auth/permissions/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/identity/auth/permissions/vitest.config.ts
Normal file
26
packages/identity/auth/permissions/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user