449 lines
14 KiB
Markdown
449 lines
14 KiB
Markdown
# OpenSpec Change: enterprise-permissions
|
|
|
|
- **Change ID**: enterprise-permissions
|
|
- **Package**: `@suaveplan/permissions`
|
|
- **Category**: universal
|
|
- **Target path**: `universal/auth/permissions`
|
|
- **Status**: PENDING
|
|
- **Date**: 2026-04-24
|
|
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/permissions.md`
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Deliver `@suaveplan/permissions` on the genesis branch from scratch at enterprise quality. The package provides a full RBAC/ABAC permissions engine: an `Ability` / `AbilityBuilder` CASL-inspired API, `PermissionManager` for role and permission CRUD, `PolicyEngine` for multi-rule policy evaluation, `RoleManager` for inheritance-aware role graphs, a `PermissionCache` layer, pluggable storage adapters, and framework-agnostic middleware. The genesis rewrite is provider-agnostic (no direct Hono/Node coupling), adds structured errors via `@suaveplan/error`, Zod option schemas, `EventBusLike` integration, injectable clock, OTel instrumentation, property-based tests, and mitata benchmarks.
|
|
|
|
---
|
|
|
|
## Current-state audit (genesis)
|
|
|
|
The package does not exist on genesis yet — this is a fresh build.
|
|
|
|
---
|
|
|
|
## Available workspace deps on genesis
|
|
|
|
| Package | Role |
|
|
|---|---|
|
|
| `@suaveplan/error` (`workspace:^`) | Base class for `PermissionsError`; `defineCodes` for `PERM.*` error codes |
|
|
| `@suaveplan/types` (`workspace:^`) | `Clock` (injectable for cache TTL), `LoggerLike`, `EventBusLike`, `StorageLike` |
|
|
| `@suaveplan/events` (`workspace:^`) | Concrete `EventBus`; lifecycle events on permission grant/revoke/check |
|
|
| `@suaveplan/logger` (`workspace:^`) | Structured logging with trace correlation |
|
|
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
|
|
| `@opentelemetry/api` (`catalog:observability`) | Spans, counters, histograms |
|
|
| `tslib` (`catalog:build`) | TypeScript runtime helpers |
|
|
|
|
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`), `@suaveplan/testing` (`workspace:^`).
|
|
|
|
---
|
|
|
|
## Preserved public API surface (from main branch)
|
|
|
|
### Types
|
|
|
|
```ts
|
|
type PermissionAction = "create" | "read" | "update" | "delete" | "list" | "manage" | "*"
|
|
type ConditionOperator = "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" | "nin" | "contains" | "startsWith" | "endsWith" | "matches"
|
|
|
|
interface Permission {
|
|
readonly id: string
|
|
readonly resource: string
|
|
readonly action: PermissionAction | string
|
|
readonly conditions?: PermissionCondition[]
|
|
readonly description?: string
|
|
}
|
|
|
|
interface PermissionCondition {
|
|
readonly field: string
|
|
readonly operator: ConditionOperator
|
|
readonly value: unknown
|
|
}
|
|
|
|
interface Role {
|
|
readonly id: string
|
|
readonly name: string
|
|
readonly permissions: Permission[]
|
|
readonly inherits?: string[]
|
|
readonly metadata?: Record<string, unknown>
|
|
readonly isSystem?: boolean
|
|
readonly siteId?: string
|
|
}
|
|
|
|
interface UserRole {
|
|
readonly id: string
|
|
readonly userId: string
|
|
readonly roleId: string
|
|
readonly siteId?: string
|
|
readonly assignedAt: Date
|
|
readonly expiresAt?: Date
|
|
readonly assignedBy?: string
|
|
}
|
|
```
|
|
|
|
### Adapter interface
|
|
|
|
```ts
|
|
interface GenericPermissionAdapter {
|
|
// Permissions
|
|
createPermission(permission: Permission): Promise<Permission>
|
|
updatePermission(id: string, updates: Partial<Permission>): Promise<Permission>
|
|
deletePermission(id: string): Promise<void>
|
|
findPermission(id: string): Promise<Permission | null>
|
|
listPermissions(filter?: PermissionFilter): Promise<Permission[]>
|
|
// Roles
|
|
createRole(role: Role): Promise<Role>
|
|
updateRole(id: string, updates: Partial<Role>): Promise<Role>
|
|
deleteRole(id: string): Promise<void>
|
|
findRole(id: string): Promise<Role | null>
|
|
listRoles(filter?: RoleFilter): Promise<Role[]>
|
|
// User-role assignments
|
|
assignRole(userRole: UserRole): Promise<UserRole>
|
|
revokeRole(userId: string, roleId: string, siteId?: string): Promise<void>
|
|
getUserRoles(userId: string, siteId?: string): Promise<UserRole[]>
|
|
getRoleUsers(roleId: string, siteId?: string): Promise<string[]>
|
|
}
|
|
|
|
// In-memory adapter
|
|
class MemoryAdapter implements GenericPermissionAdapter
|
|
|
|
// Stored shapes for adapter persistence
|
|
interface StoredPermission { ... }
|
|
interface StoredRole { ... }
|
|
interface StoredPolicy { ... }
|
|
interface StoredPolicyCondition { ... }
|
|
interface StoredUserRole { ... }
|
|
```
|
|
|
|
### Core
|
|
|
|
```ts
|
|
class PermissionManager {
|
|
constructor(adapter: GenericPermissionAdapter, options?: PermissionManagerOptions)
|
|
createPermission(permission: Permission): Promise<Permission>
|
|
updatePermission(id: string, updates: Partial<Permission>): Promise<Permission>
|
|
deletePermission(id: string): Promise<void>
|
|
findPermission(id: string): Promise<Permission | null>
|
|
listPermissions(filter?: PermissionFilter): Promise<Permission[]>
|
|
hasPermission(userId: string, resource: string, action: string, context?: Record<string, unknown>): Promise<boolean>
|
|
getUserPermissions(userId: string, siteId?: string): Promise<Permission[]>
|
|
}
|
|
|
|
class RoleManager {
|
|
constructor(adapter: GenericPermissionAdapter, options?: RoleManagerOptions)
|
|
createRole(role: Role): Promise<Role>
|
|
updateRole(id: string, updates: Partial<Role>): Promise<Role>
|
|
deleteRole(id: string): Promise<void>
|
|
findRole(id: string): Promise<Role | null>
|
|
listRoles(filter?: RoleFilter): Promise<Role[]>
|
|
assignRole(userId: string, roleId: string, options?: RoleAssignOptions): Promise<UserRole>
|
|
revokeRole(userId: string, roleId: string, siteId?: string): Promise<void>
|
|
getUserRoles(userId: string, siteId?: string): Promise<Role[]>
|
|
getRoleInheritanceChain(roleId: string): Promise<Role[]>
|
|
}
|
|
|
|
interface PolicyResult {
|
|
allowed: boolean
|
|
matchedRule?: string
|
|
reason?: string
|
|
conditions?: PermissionCondition[]
|
|
}
|
|
|
|
class PolicyEngine {
|
|
constructor(permissionManager: PermissionManager, options?: PolicyEngineOptions)
|
|
evaluate(userId: string, resource: string, action: string, context?: Record<string, unknown>): Promise<PolicyResult>
|
|
evaluateMany(requests: PolicyRequest[]): Promise<PolicyResult[]>
|
|
}
|
|
|
|
class Rule {
|
|
constructor(resource: string, action: PermissionAction | string, conditions?: PermissionCondition[])
|
|
matches(resource: string, action: string): boolean
|
|
evaluateConditions(context: Record<string, unknown>): boolean
|
|
}
|
|
```
|
|
|
|
### Ability system (CASL-inspired)
|
|
|
|
```ts
|
|
class Ability {
|
|
constructor(rules: AbilityRule[])
|
|
can(action: string, resource: string, context?: Record<string, unknown>): boolean
|
|
cannot(action: string, resource: string, context?: Record<string, unknown>): boolean
|
|
rulesFor(action: string, resource: string): AbilityRule[]
|
|
}
|
|
|
|
class AbilityBuilder {
|
|
can(action: PermissionAction | string, resource: string, conditions?: PermissionCondition[]): AbilityBuilder
|
|
cannot(action: PermissionAction | string, resource: string, conditions?: PermissionCondition[]): AbilityBuilder
|
|
build(): Ability
|
|
}
|
|
|
|
function buildAbility(fn: (builder: AbilityBuilder) => void): Ability
|
|
```
|
|
|
|
### Cache
|
|
|
|
```ts
|
|
interface PermissionCacheOptions {
|
|
ttl: number // ms
|
|
maxSize?: number
|
|
clock?: Clock
|
|
}
|
|
|
|
class PermissionCache {
|
|
constructor(options: PermissionCacheOptions)
|
|
get(key: string): boolean | undefined
|
|
set(key: string, value: boolean): void
|
|
invalidate(userId: string): void
|
|
invalidateAll(): void
|
|
readonly size: number
|
|
}
|
|
```
|
|
|
|
### Conditions
|
|
|
|
```ts
|
|
interface ConditionMatcher {
|
|
matches(condition: PermissionCondition, context: Record<string, unknown>): boolean
|
|
}
|
|
|
|
class DefaultConditionMatcher implements ConditionMatcher
|
|
function compareValues(a: unknown, b: unknown, operator: ConditionOperator): boolean
|
|
|
|
interface FieldMatcher {
|
|
get(context: Record<string, unknown>, field: string): unknown
|
|
}
|
|
|
|
class DefaultFieldMatcher implements FieldMatcher
|
|
function fieldMatches(context: Record<string, unknown>, condition: PermissionCondition): boolean
|
|
|
|
interface SubjectDetector {
|
|
detect(obj: unknown): string | undefined
|
|
}
|
|
|
|
class DefaultSubjectDetector implements SubjectDetector
|
|
```
|
|
|
|
### Events
|
|
|
|
```ts
|
|
interface PermissionEventMap {
|
|
'permission:grant': { userId: string; roleId: string; siteId?: string }
|
|
'permission:revoke': { userId: string; roleId: string; siteId?: string }
|
|
'permission:check': { userId: string; resource: string; action: string; allowed: boolean }
|
|
}
|
|
|
|
const permissionEvents: EventEmitter<PermissionEventMap>
|
|
```
|
|
|
|
### Presets
|
|
|
|
```ts
|
|
// Pre-built role sets
|
|
function createAdminRole(permissions?: Permission[]): Role
|
|
function createViewerRole(permissions?: Permission[]): Role
|
|
function createEditorRole(permissions?: Permission[]): Role
|
|
function createOwnerRole(permissions?: Permission[]): Role
|
|
|
|
// Preset permission bundles
|
|
const CRUD_PERMISSIONS: Record<string, Permission[]>
|
|
```
|
|
|
|
### Middleware
|
|
|
|
```ts
|
|
interface PermissionMiddlewareOptions {
|
|
resource: string
|
|
action: string
|
|
adapter?: GenericPermissionAdapter
|
|
}
|
|
|
|
type ResourceExtractor = (req: unknown) => string
|
|
type UserExtractor = (req: unknown) => string | null
|
|
|
|
function createPermissionMiddleware(options: PermissionMiddlewareOptions): MiddlewareFn
|
|
function checkPermission(userId: string, resource: string, action: string, adapter: GenericPermissionAdapter): Promise<PolicyResult>
|
|
```
|
|
|
|
### Error codes
|
|
|
|
```ts
|
|
type PermissionsErrorCode =
|
|
| "PERM_ROLE_NOT_FOUND"
|
|
| "PERM_PERMISSION_NOT_FOUND"
|
|
| "PERM_ASSIGNMENT_NOT_FOUND"
|
|
| "PERM_CIRCULAR_INHERITANCE"
|
|
| "PERM_SYSTEM_ROLE_MUTATION"
|
|
| "PERM_ADAPTER_ERROR"
|
|
| "PERM_CACHE_ERROR"
|
|
|
|
class PermissionsError extends SuaveplanError<PermissionsErrorCode>
|
|
function registerPermissionsErrorCodes(): void
|
|
```
|
|
|
|
---
|
|
|
|
## New additions for genesis
|
|
|
|
### 1. Structured errors via `@suaveplan/error`
|
|
|
|
`PermissionsError` extends `SuaveplanError`; all throw sites use named factory helpers keyed to the `PERM.*` code registry.
|
|
|
|
### 2. Clock injection
|
|
|
|
`PermissionCache` and `UserRole` expiry checks accept an injectable `Clock` (from `@suaveplan/types`) so TTL behaviour is deterministic in tests.
|
|
|
|
### 3. EventBus integration
|
|
|
|
`PermissionManager` and `RoleManager` accept an optional `EventBusLike`. Emit typed events from `PermissionEventMap` on every grant, revoke, and check.
|
|
|
|
### 4. OTel instrumentation
|
|
|
|
- `permissions.check` span + histogram (`duration_ms`), attribute `allowed: bool`
|
|
- `permissions.role_assign_total` / `permissions.role_revoke_total` counters
|
|
- `permissions.cache_hit_total` / `permissions.cache_miss_total` counters
|
|
- `permissions.inheritance_depth` histogram (role chain depth on lookup)
|
|
|
|
### 5. Zod schemas
|
|
|
|
`PermissionSchema`, `RoleSchema`, `UserRoleSchema`, `PermissionManagerOptionsSchema`, `RoleManagerOptionsSchema`, `PolicyEngineOptionsSchema`, `PermissionCacheOptionsSchema`.
|
|
|
|
### 6. Property-based tests (fast-check)
|
|
|
|
- Ability `can`/`cannot` consistency: `cannot(a, r)` iff `!can(a, r)` for any action/resource pair.
|
|
- Role inheritance transitivity: if role A inherits B and B inherits C, `getUserPermissions` includes C's permissions.
|
|
- Cache invalidation completeness: after `invalidate(userId)`, no stale entry remains for that user.
|
|
- Condition evaluation determinism: `compareValues(a, b, op)` returns the same result for same inputs regardless of call order.
|
|
|
|
### 7. Benchmark suite
|
|
|
|
- `PermissionManager.hasPermission` with no-cache path (direct adapter lookup) — 10 000 checks.
|
|
- `PermissionManager.hasPermission` with warm `PermissionCache` — 10 000 checks.
|
|
- `RoleManager.getRoleInheritanceChain` with 5-level deep inheritance — 10 000 calls.
|
|
- `Ability.can` evaluation on a 50-rule set — 100 000 checks.
|
|
|
|
---
|
|
|
|
## File layout
|
|
|
|
```text
|
|
universal/auth/permissions/
|
|
package.json
|
|
tsconfig.json
|
|
vitest.config.ts
|
|
src/
|
|
index.ts
|
|
error-codes.ts
|
|
constants/
|
|
index.ts # CRUD_PERMISSIONS, system role constants
|
|
types/
|
|
permissions.types.ts
|
|
permissions.types.md
|
|
adapters/
|
|
adapter-interface/
|
|
adapter-interface.ts
|
|
adapter-interface.md
|
|
adapter-interface.types.ts
|
|
index.ts
|
|
memory-adapter/
|
|
memory-adapter.ts
|
|
memory-adapter.md
|
|
memory-adapter.test.ts
|
|
index.ts
|
|
cache/
|
|
permission-cache/
|
|
permission-cache.ts
|
|
permission-cache.md
|
|
permission-cache.types.ts
|
|
permission-cache.test.ts
|
|
index.ts
|
|
conditions/
|
|
condition-matcher/
|
|
condition-matcher.ts
|
|
condition-matcher.md
|
|
condition-matcher.types.ts
|
|
condition-matcher.test.ts
|
|
index.ts
|
|
field-matcher/
|
|
field-matcher.ts
|
|
field-matcher.md
|
|
field-matcher.types.ts
|
|
field-matcher.test.ts
|
|
index.ts
|
|
subject-detector/
|
|
subject-detector.ts
|
|
subject-detector.md
|
|
subject-detector.types.ts
|
|
subject-detector.test.ts
|
|
index.ts
|
|
core/
|
|
ability/
|
|
ability.ts
|
|
ability.md
|
|
ability.test.ts
|
|
index.ts
|
|
ability-builder/
|
|
ability-builder.ts
|
|
ability-builder.md
|
|
ability-builder.test.ts
|
|
index.ts
|
|
permission-manager/
|
|
permission-manager.ts
|
|
permission-manager.md
|
|
permission-manager.types.ts
|
|
permission-manager.test.ts
|
|
index.ts
|
|
policy-engine/
|
|
policy-engine.ts
|
|
policy-engine.md
|
|
policy-engine.types.ts
|
|
policy-engine.test.ts
|
|
index.ts
|
|
role-manager/
|
|
role-manager.ts
|
|
role-manager.md
|
|
role-manager.types.ts
|
|
role-manager.test.ts
|
|
index.ts
|
|
rule/
|
|
rule.ts
|
|
rule.md
|
|
rule.test.ts
|
|
index.ts
|
|
middleware/
|
|
permission-middleware/
|
|
permission-middleware.ts
|
|
permission-middleware.md
|
|
permission-middleware.types.ts
|
|
permission-middleware.test.ts
|
|
index.ts
|
|
presets/
|
|
role-presets/
|
|
role-presets.ts
|
|
role-presets.md
|
|
role-presets.test.ts
|
|
index.ts
|
|
schemas/
|
|
permissions.schemas.ts
|
|
permissions.schemas.md
|
|
permissions.schemas.test.ts
|
|
events/
|
|
permission-events.ts
|
|
permission-events.md
|
|
__benchmarks__/
|
|
permissions.bench.ts
|
|
tests/
|
|
integration.test.ts
|
|
property.test.ts
|
|
```
|
|
|
|
---
|
|
|
|
## Impact
|
|
|
|
**Downstream packages**: `@suaveplan/auth` uses `UserContext` and `checkPermission` from this package. Future API gateway plugins will consume `PolicyEngine`.
|
|
|
|
**Breaking changes from main-branch API**: Hono-specific `createPermissionMiddleware` types are replaced with framework-agnostic equivalents; a `createHonoAdapter` bridge is provided for backward compatibility. All other symbols are preserved.
|
|
|
|
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/events`, `@suaveplan/logger` must be published to Verdaccio before this package can be built or tested.
|