Files
suaveplan/openspec/changes/permissions-completion/specs/permissions/spec.md
2026-05-27 23:29:53 +01:00

13 KiB

Capability Spec Delta: permissions (permissions-completion)

Package: @suaveplan/permissions Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time.

ADDED Requirements

Requirement: createPermissions factory wires defaults

The package SHALL expose createPermissions<TAction, TSubject, TConditions>(options?: PresetOptions<TAction, TSubject, TConditions>): PermissionsInstance<TAction, TSubject, TConditions> from presets/default-preset/default-preset.ts. The factory SHALL instantiate MemoryAdapter, AbilityBuilder, DefaultConditionMatcher, DefaultFieldMatcher, DefaultSubjectDetector, build the Ability with all three matchers wired, and return all six instances in a single Object.freezed result. Every default SHALL be overridable through PresetOptions.

Scenario: zero-config returns all six instances

  • WHEN createPermissions() is called with no arguments
  • THEN the result has defined ability, builder, adapter, conditionMatcher, fieldMatcher, subjectDetector fields AND Object.isFrozen(result) === true

Scenario: matcher overrides honoured

  • WHEN createPermissions({ conditionMatcher: customMatcher }) is called
  • THEN result.conditionMatcher === customMatcher AND the ability's condition matching delegates through customMatcher.match

Scenario: instances are independent

  • WHEN createPermissions() is called twice
  • THEN the two result.adapter instances are not the same reference, and adding rules to one does not affect the other

Verified by: packages/auth/permissions/src/presets/default-preset/default-preset.test.ts::"zero-config createPermissions returns all six wired instances" AND packages/auth/permissions/src/presets/default-preset/default-preset.test.ts::"createPermissions honours conditionMatcher / fieldMatcher / subjectDetector overrides" AND packages/auth/permissions/src/presets/default-preset/default-preset.test.ts::"two createPermissions calls return independent adapters"


Requirement: PermissionsInstance generic surface

The package SHALL define PermissionsInstance<TAction extends string, TSubject extends string, TConditions extends Record<string, unknown>> as a readonly interface with fields typed against the same generics, so callers can destructure type-safely. The package SHALL also define PresetOptions<TAction, TSubject, TConditions> carrying optional overrides for conditionMatcher, fieldMatcher, subjectDetector, adapter, builder.

Scenario: generics flow through factory

  • WHEN createPermissions<"read" | "write", "Article">() is called
  • THEN expectTypeOf(result.ability.can).parameter(0).toEqualTypeOf<"read" | "write">() and expectTypeOf(result.ability.can).parameter(1).toEqualTypeOf<"Article" | undefined>()

Verified by: packages/auth/permissions/src/presets/default-preset/default-preset.spec-conformance.test.ts::"generics flow through PermissionsInstance and PresetOptions"


Requirement: permissionEvents singleton bus

The package SHALL export a module-scope singleton permissionEvents from events/permission-events.ts, typed as EventBusLike<PermissionEventMap> from @suaveplan/events. Re-importing the module SHALL return the same instance. PermissionManager, RoleManager, and PolicyEngine SHALL emit through this singleton when no caller-supplied bus is passed via constructor options. Event names: "permission:checked", "permission:granted", "permission:denied", "role:assigned", "role:revoked". Payloads MUST match PermissionEventMap.

Scenario: singleton invariant

  • WHEN permissionEvents is imported from two different modules in the same process
  • THEN both references point to the same instance (a === b)

Scenario: manager emits via singleton when bus not passed

  • WHEN new PermissionManager({...without eventBus...}) is constructed and a permission check is performed
  • THEN a permission:checked listener registered on permissionEvents fires with the documented payload shape

Scenario: caller-supplied bus overrides singleton

  • WHEN new PermissionManager({ eventBus: customBus }) is constructed and a permission check is performed
  • THEN a permission:checked listener on customBus fires AND a listener on permissionEvents does NOT fire (no double-emit)

Verified by: packages/auth/permissions/src/events/permission-events.test.ts::"permissionEvents is a process-wide singleton across imports" AND packages/auth/permissions/src/events/permission-events.test.ts::"PermissionManager emits to singleton when no eventBus passed" AND packages/auth/permissions/src/events/permission-events.test.ts::"caller-supplied bus overrides singleton with no double-emit" AND packages/auth/permissions/src/events/permission-events.spec-conformance.test.ts::"permissionEvents satisfies EventBusLike<PermissionEventMap>"


Requirement: deepGet / deepHas dot-path utilities

The package SHALL expose deepGet(obj: unknown, path: string): unknown and deepHas(obj: unknown, path: string): boolean from utils/deep-get/deep-get.ts. The functions SHALL accept dot-notation paths and SHALL also accept numeric segments that resolve to array indices (e.g. tags.0 reads obj.tags[0]). When any segment is missing or current is null / undefined / non-object, deepGet SHALL return undefined and deepHas SHALL return false. The functions SHALL NOT throw on any input.

Scenario: dot-path resolves nested properties

  • WHEN deepGet({ user: { profile: { role: "admin" } } }, "user.profile.role") is called
  • THEN the result is "admin"

Scenario: numeric segment resolves array index

  • WHEN deepGet({ tags: ["a", "b", "c"] }, "tags.1") is called
  • THEN the result is "b"

Scenario: missing segment returns undefined

  • WHEN deepGet({ user: { profile: {} } }, "user.profile.role") is called
  • THEN the result is undefined

Scenario: null-safe traversal

  • WHEN deepGet(null, "anything") or deepGet({}, "user.profile.role") is called
  • THEN the result is undefined (no exception)

Scenario: deepHas agrees with deepGet

  • WHEN any fixture is queried with deepHas(obj, path)
  • THEN the result equals deepGet(obj, path) !== undefined

Verified by: packages/auth/permissions/src/utils/deep-get/deep-get.test.ts::"resolves nested object properties via dot notation" AND packages/auth/permissions/src/utils/deep-get/deep-get.test.ts::"resolves numeric segments as array indices" AND packages/auth/permissions/src/utils/deep-get/deep-get.test.ts::"returns undefined for any missing segment" AND packages/auth/permissions/src/utils/deep-get/deep-get.test.ts::"is null-safe and never throws" AND packages/auth/permissions/src/utils/deep-get/deep-get.spec-conformance.test.ts::"deepHas agrees with deepGet for every fixture"


Requirement: matchPattern / patternToRegex glob utilities

The package SHALL expose matchPattern(input: string, pattern: string): boolean and patternToRegex(pattern: string): RegExp from utils/pattern-match/pattern-match.ts. Patterns: * and the literal string "manage" SHALL match every input; foo* SHALL match by prefix; *foo SHALL match by suffix; foo*bar SHALL match by combined prefix-and-suffix; otherwise exact equality. Special regex characters in non-wildcard portions SHALL be escaped before compilation. The compiled RegExp SHALL be cached in a module-scope Map<string, RegExp> keyed by pattern string with insertion-order eviction at 1024 entries.

Scenario: exact match

  • WHEN matchPattern("read", "read") is called
  • THEN the result is true

Scenario: wildcard "*" matches everything

  • WHEN matchPattern("anything", "*") is called
  • THEN the result is true

Scenario: "manage" matches everything

  • WHEN matchPattern("read", "manage") is called
  • THEN the result is true

Scenario: prefix wildcard

  • WHEN matchPattern("readArticle", "read*") is called
  • THEN the result is true

Scenario: suffix wildcard

  • WHEN matchPattern("getArticle", "*Article") is called
  • THEN the result is true

Scenario: special regex characters escaped

  • WHEN matchPattern("a.b", "a.b") is called (literal dot, not wildcard)
  • THEN the result is true AND matchPattern("axb", "a.b") returns false

Scenario: RegExp cache reuse

  • WHEN patternToRegex("read*") is called twice
  • THEN both calls return the same RegExp instance (a === b)

Scenario: cache eviction at 1024 entries

  • WHEN 1025 unique patterns are passed to patternToRegex
  • THEN the first inserted pattern is evicted (calling patternToRegex for it again returns a new RegExp instance, not the original)

Verified by: packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"matches by exact equality" AND packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"\"*\" matches every input" AND packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"\"manage\" matches every input" AND packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"prefix and suffix wildcards work" AND packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts::"escapes special regex characters in literal portions" AND packages/auth/permissions/src/utils/pattern-match/pattern-match.spec-conformance.test.ts::"reuses cached RegExp for the same pattern" AND packages/auth/permissions/src/utils/pattern-match/pattern-match.spec-conformance.test.ts::"evicts in insertion order at 1024 entries"

MODIFIED Requirements

The three behavior extensions below modify existing genesis surface (no requirement currently exists in openspec/specs/permissions/spec.md because no permissions cap file has been folded yet — these are quoted in full so reviewers see the new contract clearly).

Requirement: DefaultConditionMatcher resolves dot-path keys

The DefaultConditionMatcher.match(conditions, context) method SHALL resolve every condition key through deepGet(context, key) rather than context[key]. This means a condition like { "user.role": { eq: "admin" } } SHALL resolve to context.user.role and compare against "admin". Top-level keys SHALL continue to work via deepGet returning the immediate value.

Scenario: dot-path key resolves nested context

  • WHEN matcher.match({ "user.role": { eq: "admin" } }, { user: { role: "admin" } }) is called
  • THEN the result is true

Scenario: top-level key still works

  • WHEN matcher.match({ role: { eq: "admin" } }, { role: "admin" }) is called
  • THEN the result is true (regression check)

Verified by: packages/auth/permissions/src/conditions/condition-matcher/condition-matcher.test.ts::"resolves dot-path condition keys via deepGet" AND packages/auth/permissions/src/conditions/condition-matcher/condition-matcher.test.ts::"top-level condition keys continue to resolve"


Requirement: DefaultFieldMatcher delegates to matchPattern

The DefaultFieldMatcher.match(pattern, field) method SHALL delegate to matchPattern(field, pattern), replacing the existing exact-match-plus-hardcoded-*-only logic. The fast path of byte-equal pattern === field SHALL be preserved for the common case, but every other path SHALL go through matchPattern.

Scenario: prefix wildcard matches

  • WHEN fieldMatcher.match("field*", "fieldName") is called
  • THEN the result is true

Scenario: suffix wildcard matches

  • WHEN fieldMatcher.match("*Name", "firstName") is called
  • THEN the result is true

Scenario: exact equality fast path preserved

  • WHEN fieldMatcher.match("title", "title") is called
  • THEN the result is true AND no RegExp compilation occurs (verified by spying on patternToRegex)

Verified by: packages/auth/permissions/src/conditions/field-matcher/field-matcher.test.ts::"matches by prefix and suffix wildcards" AND packages/auth/permissions/src/conditions/field-matcher/field-matcher.test.ts::"exact-equality fast path skips RegExp compilation"


Requirement: Ability.can honors registered wildcard actions

The Ability.can(action, subject?, field?) method SHALL match a registered rule whose action is *, "manage", or contains * as a wildcard against the supplied action. Matching SHALL delegate to matchPattern(action, rule.action). Likewise for subject matching against rule.subject.

Scenario: wildcard action rule matches concrete action

  • WHEN a rule { action: "read*", subject: "Article" } is registered AND ability.can("readMeta", "Article") is called
  • THEN the result is true

Scenario: "manage" rule matches every action

  • WHEN a rule { action: "manage", subject: "Article" } is registered AND ability.can("delete", "Article") is called
  • THEN the result is true

Scenario: subject wildcard matches concrete subject

  • WHEN a rule { action: "read", subject: "*" } is registered AND ability.can("read", "Article") is called
  • THEN the result is true

Verified by: packages/auth/permissions/src/core/ability/ability.test.ts::"can honours registered wildcard action via matchPattern" AND packages/auth/permissions/src/core/ability/ability.test.ts::"can honours \"manage\" wildcard action" AND packages/auth/permissions/src/core/ability/ability.test.ts::"can honours wildcard subject"