Initial Commit
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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.freeze`d 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user