Files
suaveplan/openspec/archive/2026-04-24-enterprise-permissions/proposal.md
2026-05-27 23:29:53 +01:00

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.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

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.