Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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,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,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).
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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,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,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.
|
||||
Reference in New Issue
Block a user