14 KiB
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
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
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
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)
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
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
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
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
// 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
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
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.checkspan + histogram (duration_ms), attributeallowed: boolpermissions.role_assign_total/permissions.role_revoke_totalcounterspermissions.cache_hit_total/permissions.cache_miss_totalcounterspermissions.inheritance_depthhistogram (role chain depth on lookup)
5. Zod schemas
PermissionSchema, RoleSchema, UserRoleSchema, PermissionManagerOptionsSchema, RoleManagerOptionsSchema, PolicyEngineOptionsSchema, PermissionCacheOptionsSchema.
6. Property-based tests (fast-check)
- Ability
can/cannotconsistency:cannot(a, r)iff!can(a, r)for any action/resource pair. - Role inheritance transitivity: if role A inherits B and B inherits C,
getUserPermissionsincludes 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.hasPermissionwith no-cache path (direct adapter lookup) — 10 000 checks.PermissionManager.hasPermissionwith warmPermissionCache— 10 000 checks.RoleManager.getRoleInheritanceChainwith 5-level deep inheritance — 10 000 calls.Ability.canevaluation on a 50-rule set — 100 000 checks.
File layout
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.