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,subjectDetectorfields ANDObject.isFrozen(result) === true
Scenario: matcher overrides honoured
- WHEN
createPermissions({ conditionMatcher: customMatcher })is called - THEN
result.conditionMatcher === customMatcherAND the ability's condition matching delegates throughcustomMatcher.match
Scenario: instances are independent
- WHEN
createPermissions()is called twice - THEN the two
result.adapterinstances 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">()andexpectTypeOf(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
permissionEventsis 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:checkedlistener registered onpermissionEventsfires 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:checkedlistener oncustomBusfires AND a listener onpermissionEventsdoes 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")ordeepGet({}, "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
trueANDmatchPattern("axb", "a.b")returnsfalse
Scenario: RegExp cache reuse
- WHEN
patternToRegex("read*")is called twice - THEN both calls return the same
RegExpinstance (a === b)
Scenario: cache eviction at 1024 entries
- WHEN 1025 unique patterns are passed to
patternToRegex - THEN the first inserted pattern is evicted (calling
patternToRegexfor it again returns a newRegExpinstance, 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.mdbecause nopermissionscap 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
trueAND noRegExpcompilation occurs (verified by spying onpatternToRegex)
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 ANDability.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 ANDability.can("delete", "Article")is called - THEN the result is
true
Scenario: subject wildcard matches concrete subject
- WHEN a rule
{ action: "read", subject: "*" }is registered ANDability.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"