first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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.