Initial Commit
This commit is contained in:
99
openspec/changes/permissions-completion/proposal.md
Normal file
99
openspec/changes/permissions-completion/proposal.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Permissions completion — default-preset factory, event singleton, deep-path utils, pattern-match utils
|
||||
|
||||
**Package**: `@suaveplan/permissions`
|
||||
**Status**: PROPOSED
|
||||
**Type**: feature
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-05-02 audit of `packages/auth/permissions/src` against `../suaveplan/packages/universal/permissions/src` identified four confirmed feature gaps. The genesis `permissions` package has the heavy machinery (managers, cache, middleware, policy engine, role/ability builders, condition / field / subject matchers) but is missing four small ergonomic helpers that upstream callers rely on: a `createPermissions()` factory that wires the defaults together, a singleton `permissionEvents` event bus, `deepGet` / `deepHas` for condition-matching against nested input shapes, and `matchPattern` / `patternToRegex` for wildcard-style action matching.
|
||||
|
||||
Without these helpers, every consumer of `@suaveplan/permissions` has to hand-roll the same boilerplate (instantiate `MemoryAdapter`, `AbilityBuilder`, `DefaultConditionMatcher`, `DefaultFieldMatcher`, `DefaultSubjectDetector`; wire the matchers into `builder.build`; then track and bind the result). Worse, every consumer that wants to listen for `permission:granted` / `permission:denied` events has to create its own `EventEmitter` and pass it through the manager constructor, which leads to two un-shared event buses on every realistic deployment. Both gaps are small but high-friction; closing them in one change avoids four sequential micro-changes and lets the upstream test fixtures port verbatim.
|
||||
|
||||
The four gaps:
|
||||
|
||||
1. **`createPermissions()` factory + `PermissionsInstance` interface** (97 L upstream `presets/default-preset/default-preset.ts`) — returns a single bag of `{ ability, builder, adapter, conditionMatcher, fieldMatcher, subjectDetector }` so consumers can destructure rather than instantiating six classes by hand. Every default is overridable through a single `PresetOptions` arg.
|
||||
2. **`permissionEvents` singleton event bus** (57 L upstream `permission-events.ts`) — a process-wide `EventBusLike` from `@suaveplan/events` keyed by `"permission:checked" | "permission:granted" | "permission:denied" | "role:assigned" | "role:revoked"`. Genesis already exports the `PermissionEventMap` type but never wires a singleton — managers either accept an optional event bus (which most callers leave undefined) or emit nothing.
|
||||
3. **`deepGet(obj, path)` and `deepHas(obj, path)` utilities** (47 L upstream `utils/deep-get.ts`) — typed dot-path getters used by `DefaultConditionMatcher` to read `user.profile.role` or similar nested context fields. The genesis condition matcher currently only accepts top-level keys, blocking realistic conditions.
|
||||
4. **`matchPattern(input, pattern)` and `patternToRegex(pattern)` utilities** (54 L upstream `utils/pattern-matching.ts`) — glob-style matching (`*`, `manage`, prefix `foo*`, suffix `*foo`) compiled to RegExp. Used by `DefaultFieldMatcher` and by `Ability.can(action, subject)` for wildcard action / subject matching, which the genesis `WILDCARD_ACTION` constant already presupposes but does not implement.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `presets/default-preset/default-preset.ts` exporting `createPermissions<TAction, TSubject, TConditions>(options?: PresetOptions<...>): PermissionsInstance<TAction, TSubject, TConditions>`. The factory wires `MemoryAdapter`, `AbilityBuilder`, `DefaultConditionMatcher`, `DefaultFieldMatcher`, `DefaultSubjectDetector`, builds the `Ability`, and returns all six instances in a frozen result object. Every default is overridable via `PresetOptions`.
|
||||
- ADDED: `presets/default-preset/default-preset.types.ts` exporting `PresetOptions<TAction, TSubject, TConditions>` (overrides for each of the six injected dependencies) and `PermissionsInstance<TAction, TSubject, TConditions>` (the result shape).
|
||||
- ADDED: `events/permission-events.ts` `permissionEvents` exported singleton — a module-scope `EventBusLike` instance keyed by the existing `PermissionEventMap`. Managers and the ability builder SHALL emit through it when no caller-supplied bus is passed. The existing `PermissionEventMap` type stays exported as before.
|
||||
- ADDED: `utils/deep-get/deep-get.ts` exporting `deepGet(obj: unknown, path: string): unknown` and `deepHas(obj: unknown, path: string): boolean`. Supports dot-notation (`user.profile.role`) and array-index segments (`tags.0` resolves `obj.tags[0]`). Returns `undefined` from `deepGet` (and `false` from `deepHas`) when any segment is missing rather than throwing.
|
||||
- ADDED: `utils/pattern-match/pattern-match.ts` exporting `matchPattern(input: string, pattern: string): boolean` and `patternToRegex(pattern: string): RegExp`. Patterns: `*` and `"manage"` match everything, `foo*` matches by prefix, `*foo` matches by suffix, otherwise exact-equality. The compiled `RegExp` is cached by pattern string in a module-scope `Map<string, RegExp>` so repeated lookups (the hot path inside `Ability.can`) do not re-compile.
|
||||
- MODIFIED: `conditions/condition-matcher/condition-matcher.ts` — `DefaultConditionMatcher.match(conditions, context)` SHALL use `deepGet(context, key)` to resolve dot-notation keys (currently it only resolves top-level keys via `context[key]`).
|
||||
- MODIFIED: `conditions/field-matcher/field-matcher.ts` — `DefaultFieldMatcher.match(pattern, field)` SHALL delegate to `matchPattern(field, pattern)` (currently it only supports exact equality and a hard-coded `*` wildcard).
|
||||
- MODIFIED: `core/ability/ability.ts` — `Ability.can(action, subject?, field?)` action / subject lookup SHALL delegate to `matchPattern(input, ruleAction)` so registered wildcard actions (e.g. `read*`, `*:archive`) match. Currently the `WILDCARD_ACTION` constant exists but `Ability.can` does not honor it.
|
||||
- MODIFIED: `package.json` adds subpath exports `./presets/default-preset/default-preset`, `./events/permission-events`, `./utils/deep-get/deep-get`, `./utils/pattern-match/pattern-match`.
|
||||
- MODIFIED: `index.ts` re-exports the four new public symbols (`createPermissions`, `permissionEvents`, `deepGet`, `deepHas`, `matchPattern`, `patternToRegex`, `PermissionsInstance`, `PresetOptions`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **GraphQL field-level permissions middleware** — separate change. Upstream has a `middleware/graphql-permissions.ts` (~180 L) that intercepts `GraphQLResolveInfo`. Genesis `permissions` is universal-tier and cannot depend on `graphql`; if we want this it belongs in a new `@suaveplan/permissions-graphql` browser-tier package.
|
||||
- **CASL pure-class subject system** — upstream supports passing a class constructor as a subject (`subject: ArticleClass`) and resolves the subject string via `subject.modelName`. Genesis intentionally chose typed-pattern matching (string subjects only) to avoid pulling reflection at runtime; documented in the genesis `permissions` README. Out of scope here.
|
||||
- **Async condition matchers** — upstream's `DefaultConditionMatcher.match` is synchronous; some downstream callers want an async variant for "is the user a member of this org?" lookups. That requires an `AsyncConditionMatcher` interface and an `Ability.canAsync` companion — meaningfully larger surface change, out of scope.
|
||||
- **Reactive bindings** — React hooks (`usePermission`, `usePermissionRule`) live in the browser-tier `@suaveplan/react-permissions` package (which already exists as a separate "enterprise-react-permissions" change). Not a `permissions` core concern.
|
||||
- **Pattern-match anchor configurability** — upstream's `patternToRegex` always anchors with `^` and `$`. Some callers want unanchored "contains" semantics for log-style matching. We keep the upstream anchored semantics; unanchored variants belong in a `permissions-search` follow-up.
|
||||
|
||||
## Source mapping
|
||||
|
||||
Genesis `enterprise/permissions.md` is a generic enterprise-quality template (no per-feature requirements), so source mapping cites the upstream package source directly.
|
||||
|
||||
| Requirement | Source file | Source lines | Notes |
|
||||
|---|---|---|---|
|
||||
| `createPermissions` factory | `packages/universal/permissions/src/presets/default-preset/default-preset.ts` | L60-L97 | Verbatim port |
|
||||
| `PermissionsInstance` interface | `packages/universal/permissions/src/presets/default-preset/default-preset.ts` | L23-L40 | Verbatim |
|
||||
| `PresetOptions` interface | `packages/universal/permissions/src/presets/default-preset/default-preset.types.ts` | L1-L26 | Verbatim |
|
||||
| `permissionEvents` singleton | `packages/universal/permissions/src/permission-events.ts` | L11-L57 | Verbatim port; replace upstream `EventEmitter` (custom class) with `EventBusLike` from `@suaveplan/events` to match genesis-wide bus contract |
|
||||
| `deepGet` | `packages/universal/permissions/src/utils/deep-get.ts` | L21-L36 | Verbatim |
|
||||
| `deepHas` | `packages/universal/permissions/src/utils/deep-get.ts` | L45-L47 | Verbatim |
|
||||
| `matchPattern` | `packages/universal/permissions/src/utils/pattern-matching.ts` | L20-L30 | Verbatim |
|
||||
| `patternToRegex` | `packages/universal/permissions/src/utils/pattern-matching.ts` | L41-L44 | Verbatim port; ADD module-scope `Map<string, RegExp>` cache (genesis-only optimisation; upstream re-compiles every call) |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `permissionEvents` typed against `@suaveplan/types::EventBusLike` (rather than upstream's custom `EventEmitter` class) | Genesis-wide event-bus contract policy: every cross-cutting bus uses `EventBusLike` so callers can swap in their own bus. Upstream uses a self-defined `EventEmitter`; that breaks consumer composition. |
|
||||
| `RegExp` cache in `patternToRegex` | Genesis-wide hot-path optimisation policy. `Ability.can` is called on every check; pattern `RegExp` compilation is the hottest unbounded allocation. Bounding the cache (LRU at 1024 entries, eviction on insertion) is genesis-original. |
|
||||
| `deepGet` array-index support (`tags.0`) | Upstream supports only object-key segments; conditions like `firstTag === user.tags[0]` could not be written. Adding numeric segments is a small extension that opens the realistic use case without breaking existing callers. |
|
||||
| `Ability.can` action wildcard via `matchPattern` | Upstream `Ability.can` does honor `manage` and `*` wildcards via inline checks. Genesis `Ability.can` already imports `WILDCARD_ACTION` but does not actually use it; this change makes the wildcard resolution match the upstream behavior — wired through the new `matchPattern` util. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Each spec delta requirement carries a `**Verified by:**` line pointing at concrete `it(...)` titles. The test files that will exist after this change ships:
|
||||
|
||||
- `packages/auth/permissions/src/presets/default-preset/default-preset.test.ts` — destructure-and-use, matcher overrides, adapter override, multiple instances are independent
|
||||
- `packages/auth/permissions/src/presets/default-preset/default-preset.spec-conformance.test.ts` — generics flow through (`expectTypeOf`), result is frozen, options validated
|
||||
- `packages/auth/permissions/src/events/permission-events.test.ts` — listener registration, event payload shapes, manager auto-emits when no bus is passed
|
||||
- `packages/auth/permissions/src/events/permission-events.spec-conformance.test.ts` — singleton invariant (multiple imports return the same instance), `EventBusLike` contract pinned
|
||||
- `packages/auth/permissions/src/utils/deep-get/deep-get.test.ts` — dot-path resolution, array-index resolution, missing-segment returns undefined, null-safe traversal
|
||||
- `packages/auth/permissions/src/utils/deep-get/deep-get.spec-conformance.test.ts` — `deepHas` agrees with `deepGet !== undefined` for every fixture
|
||||
- `packages/auth/permissions/src/utils/pattern-match/pattern-match.test.ts` — exact match, `*` wildcard, `manage` wildcard, prefix, suffix, both-side wildcard, escaped special characters
|
||||
- `packages/auth/permissions/src/utils/pattern-match/pattern-match.spec-conformance.test.ts` — RegExp cache reuse for repeated patterns; cache eviction at 1024 entries
|
||||
- `packages/auth/permissions/src/conditions/condition-matcher/condition-matcher.test.ts` — extended with dot-path keys (`user.profile.role`)
|
||||
- `packages/auth/permissions/src/conditions/field-matcher/field-matcher.test.ts` — extended with wildcard-pattern matching (`field*`, `*field`)
|
||||
- `packages/auth/permissions/src/core/ability/ability.test.ts` — extended with wildcard-action matching (`read*` matches `read:article`)
|
||||
|
||||
## Migration impact
|
||||
|
||||
This change is **mostly additive** — it adds four new public symbols and three small behavior changes inside existing implementations.
|
||||
|
||||
The three `MODIFIED` items (in `condition-matcher`, `field-matcher`, `ability`) are behavior-compatible extensions: every existing test continues to pass because the new behaviors only activate when the input contains a `.` (dot-path) or `*` (wildcard pattern) — neither of which existing tests exercise as a string literal. The full sweep is part of §9 of `tasks.md`.
|
||||
|
||||
Affected packages:
|
||||
|
||||
- **None outside `@suaveplan/permissions`** — `bunx turbo ls --filter=...@suaveplan/permissions` returns only `@suaveplan/permissions` itself. No internal consumer exists today (verified by `grep -rl "@suaveplan/permissions" /home/ubuntu/web/genesis/packages/ --include="package.json" | grep -v node_modules` returning only the package itself).
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/permissions` to enumerate. For each dependent, check one box:
|
||||
|
||||
- (No dependents in genesis today — verified by `grep -rl "@suaveplan/permissions" /home/ubuntu/web/genesis/packages/ --include="package.json" | grep -v node_modules` returning only `packages/auth/permissions/package.json`.)
|
||||
|
||||
When the upstream `@suaveplan/auth` migrates into genesis, it will consume `createPermissions` and `permissionEvents`. The four exports are stable from day one of this change so `auth`'s migration is a non-issue.
|
||||
@@ -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 -->
|
||||
132
openspec/changes/permissions-completion/tasks.md
Normal file
132
openspec/changes/permissions-completion/tasks.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Tasks — Permissions completion
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Read `../suaveplan/packages/universal/permissions/src/presets/default-preset/default-preset.ts` and `default-preset.types.ts` end-to-end
|
||||
- [ ] 1.2 Read `../suaveplan/packages/universal/permissions/src/permission-events.ts` end-to-end
|
||||
- [ ] 1.3 Read `../suaveplan/packages/universal/permissions/src/utils/deep-get.ts` and `utils/pattern-matching.ts` end-to-end
|
||||
- [ ] 1.4 Read genesis `packages/auth/permissions/src/{core,conditions,adapters,events,presets}` to confirm what exists
|
||||
- [ ] 1.5 Read `openspec/AGENTS.md` Sections 13 (spec-conformance) and 14 (source mapping)
|
||||
- [ ] 1.6 Draft `proposal.md` with explicit ADDED / MODIFIED lists and a `## Source mapping` table citing upstream line ranges
|
||||
- [ ] 1.7 Draft `specs/permissions/spec.md` with an `## ADDED Requirements` section per gap and `## MODIFIED Requirements` for the three behavior extensions
|
||||
- [ ] 1.8 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
- [ ] 1.9 `openspec change validate permissions-completion` exits with no errors
|
||||
|
||||
## 2. `createPermissions` factory + `PermissionsInstance`
|
||||
|
||||
- [ ] 2.1 Create `packages/auth/permissions/src/presets/default-preset/default-preset.types.ts` with `PresetOptions<TAction, TSubject, TConditions>` (overrides for `conditionMatcher`, `fieldMatcher`, `subjectDetector`, `adapter`, `builder`)
|
||||
- [ ] 2.2 Add `PermissionsInstance<TAction, TSubject, TConditions>` interface with readonly fields `ability`, `builder`, `adapter`, `conditionMatcher`, `fieldMatcher`, `subjectDetector`
|
||||
- [ ] 2.3 Port `createPermissions` from upstream `default-preset.ts` L60-L97 into `packages/auth/permissions/src/presets/default-preset/default-preset.ts`. Preserve generic parameters (`TAction extends string = DefaultAction`, etc.)
|
||||
- [ ] 2.4 Wire `MemoryAdapter`, `AbilityBuilder`, `DefaultConditionMatcher`, `DefaultFieldMatcher`, `DefaultSubjectDetector` defaults; honour every override from `PresetOptions`
|
||||
- [ ] 2.5 `Object.freeze` the returned `PermissionsInstance` so callers can't mutate the matcher slot post-creation
|
||||
- [ ] 2.6 Add subpath export `./presets/default-preset/default-preset` in `package.json`
|
||||
- [ ] 2.7 Re-export `createPermissions`, `PermissionsInstance`, `PresetOptions` from `src/index.ts`
|
||||
- [ ] 2.8 Sibling `default-preset.md` (≥200 words, basic + advanced examples)
|
||||
|
||||
## 3. `permissionEvents` singleton
|
||||
|
||||
- [ ] 3.1 Modify `packages/auth/permissions/src/events/permission-events.ts` to export a module-scope `permissionEvents` singleton typed as `EventBusLike<PermissionEventMap>` from `@suaveplan/events`
|
||||
- [ ] 3.2 Use the genesis-canonical `createEventBus({ name: "permissions" })` factory (not the upstream custom `EventEmitter` class)
|
||||
- [ ] 3.3 Update `core/permission-manager/permission-manager.ts` to default to `permissionEvents` when no bus is passed via constructor options
|
||||
- [ ] 3.4 Update `core/role-manager/role-manager.ts` likewise
|
||||
- [ ] 3.5 Update `core/policy-engine/policy-engine.ts` to emit `permission:checked` through the singleton
|
||||
- [ ] 3.6 Add subpath export `./events/permission-events` in `package.json`
|
||||
- [ ] 3.7 Re-export `permissionEvents` from `src/index.ts`
|
||||
- [ ] 3.8 Sibling `permission-events.md` updated to document the singleton (≥200 words, basic listener + advanced filter-by-userId examples)
|
||||
|
||||
## 4. `deepGet` / `deepHas` utilities
|
||||
|
||||
- [ ] 4.1 Create `packages/auth/permissions/src/utils/deep-get/deep-get.ts` with `deepGet(obj, path)` and `deepHas(obj, path)`
|
||||
- [ ] 4.2 Support dot-notation segments (`user.profile.role`)
|
||||
- [ ] 4.3 Support numeric array-index segments (`tags.0` resolves `obj.tags[0]`); detected by `/^\d+$/.test(segment)`
|
||||
- [ ] 4.4 Return `undefined` from `deepGet` (and `false` from `deepHas`) when any segment is missing or `current` is `null` / `undefined` / non-object
|
||||
- [ ] 4.5 Update `conditions/condition-matcher/condition-matcher.ts` `DefaultConditionMatcher.match(conditions, context)` to use `deepGet(context, key)` instead of `context[key as keyof typeof context]`
|
||||
- [ ] 4.6 Add subpath export `./utils/deep-get/deep-get` in `package.json`
|
||||
- [ ] 4.7 Re-export `deepGet`, `deepHas` from `src/index.ts`
|
||||
- [ ] 4.8 Sibling `deep-get.md` (≥200 words, basic + advanced array-index example)
|
||||
|
||||
## 5. `matchPattern` / `patternToRegex` utilities
|
||||
|
||||
- [ ] 5.1 Create `packages/auth/permissions/src/utils/pattern-match/pattern-match.ts` with `matchPattern(input, pattern)` and `patternToRegex(pattern)`
|
||||
- [ ] 5.2 Patterns: `*` and `"manage"` match everything; `foo*` is prefix match; `*foo` is suffix match; `foo*bar` is prefix + suffix; otherwise exact equality
|
||||
- [ ] 5.3 Escape regex special characters in non-wildcard portions before compiling: `.`, `+`, `?`, `^`, `$`, `{`, `}`, `(`, `)`, `|`, `[`, `]`, `\`
|
||||
- [ ] 5.4 Cache compiled `RegExp` in a module-scope `Map<string, RegExp>` keyed by pattern string
|
||||
- [ ] 5.5 LRU-evict at 1024 entries (insertion-order eviction via `Map.keys().next().value`)
|
||||
- [ ] 5.6 Update `conditions/field-matcher/field-matcher.ts` `DefaultFieldMatcher.match(pattern, field)` to delegate to `matchPattern(field, pattern)` (keeping the existing exact-match fast path before pattern parse)
|
||||
- [ ] 5.7 Update `core/ability/ability.ts` `Ability.can` action / subject lookup to delegate to `matchPattern` so registered wildcard rules (e.g. `can("read*", "Article")`) are honoured
|
||||
- [ ] 5.8 Add subpath export `./utils/pattern-match/pattern-match` in `package.json`
|
||||
- [ ] 5.9 Re-export `matchPattern`, `patternToRegex` from `src/index.ts`
|
||||
- [ ] 5.10 Sibling `pattern-match.md` (≥200 words, basic + advanced example showing both prefix and suffix wildcards)
|
||||
|
||||
## 6. Tests
|
||||
|
||||
- [ ] 6.1 `presets/default-preset/default-preset.test.ts` — destructure-and-use; matcher overrides honoured; adapter override honoured; multiple instances are independent (changing one's matcher does not affect the other)
|
||||
- [ ] 6.2 `presets/default-preset/default-preset.spec-conformance.test.ts` — `expectTypeOf` checks generics flow through; result is `Object.isFrozen`; `PresetOptions` accepts every documented override
|
||||
- [ ] 6.3 `events/permission-events.test.ts` — listener registration; payload shapes for all 5 event names; manager auto-emits `permission:checked` and `permission:granted` when no bus passed
|
||||
- [ ] 6.4 `events/permission-events.spec-conformance.test.ts` — re-importing the module returns the same `permissionEvents` instance; `EventBusLike<PermissionEventMap>` contract pinned (`expectTypeOf<typeof permissionEvents>().toMatchTypeOf<EventBusLike<PermissionEventMap>>()`)
|
||||
- [ ] 6.5 `utils/deep-get/deep-get.test.ts` — dot-path resolution at depth 3; array-index resolution; missing segment returns `undefined`; null-safe traversal; `deepHas` reports correctly
|
||||
- [ ] 6.6 `utils/deep-get/deep-get.spec-conformance.test.ts` — `deepHas(obj, p) === (deepGet(obj, p) !== undefined)` for every test fixture
|
||||
- [ ] 6.7 `utils/pattern-match/pattern-match.test.ts` — exact match; `*` wildcard; `manage` wildcard; `foo*` prefix; `*foo` suffix; `foo*bar` both-side; special-char input must not be matched accidentally (e.g. `.` in input)
|
||||
- [ ] 6.8 `utils/pattern-match/pattern-match.spec-conformance.test.ts` — RegExp cache: same pattern returns same RegExp instance; cache evicts at 1024 entries (insertion-order)
|
||||
- [ ] 6.9 `conditions/condition-matcher/condition-matcher.test.ts` — extend with dot-path keys (`user.profile.role` resolves through `deepGet`)
|
||||
- [ ] 6.10 `conditions/field-matcher/field-matcher.test.ts` — extend with wildcard-pattern matching (`field*` matches `fieldName`; `*Name` matches `firstName`)
|
||||
- [ ] 6.11 `core/ability/ability.test.ts` — extend with wildcard-action matching (`can("read*", "Article")` matches `ability.can("read", "Article")` and `ability.can("readMeta", "Article")`)
|
||||
- [ ] 6.12 Every `it(...)` in 6.1–6.11 carries `{ timeout: 5000 }`
|
||||
|
||||
## 7. Documentation
|
||||
|
||||
- [ ] 7.1 Sibling `.md` for every new implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [ ] 7.2 Update `packages/auth/permissions/README.md` to link the four new co-located `.md` files under `## Modules`
|
||||
- [ ] 7.3 Update `CLAUDE.md` package listing for `@suaveplan/permissions` to reflect the four new exports
|
||||
|
||||
## 8. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 8.1 `bun run check` — Biome 0/0/0 across the whole monorepo
|
||||
- [ ] 8.2 `bunx turbo lint typecheck test --filter=@suaveplan/permissions` — green
|
||||
- [ ] 8.3 `bunx turbo test:coverage --filter=@suaveplan/permissions` — 100/100/100/100
|
||||
- [ ] 8.4 `bun scripts/openspec-conformance.ts @suaveplan/permissions` — exit 0, no `error` findings
|
||||
- [ ] 8.5 `bunx turbo build --filter=@suaveplan/permissions` — green
|
||||
|
||||
## 9. Pre-archive 7-row checklist (AGENTS.md Section 13)
|
||||
|
||||
- [ ] 9.1 Generics threaded; `expectTypeOf` assertions on `PermissionsInstance<TAction, TSubject, TConditions>` and `PresetOptions<...>`
|
||||
- [ ] 9.2 Zod schemas wired at every declared boundary — `PresetOptions` validated at `createPermissions` entry; `permissionEvents` payloads typed against `PermissionEventMap`
|
||||
- [ ] 9.3 Every existing error code in `errors/permission-error.ts` still thrown by at least one production path (no orphans introduced by the refactor)
|
||||
- [ ] 9.4 Strict-mode behaviors tested both on and off (e.g. `Ability.can` strict-action mode vs. wildcard mode)
|
||||
- [ ] 9.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`: `permissions.checks_total`, `permissions.grants_total`, `permissions.denies_total`, `permissions.event_emit_count`
|
||||
- [ ] 9.6 Lifecycle dedup tested where claimed: re-importing `events/permission-events.ts` returns the same singleton; `patternToRegex` cache reuse
|
||||
- [ ] 9.7 No retroactive tick — every box above reflects work done in this change
|
||||
|
||||
## 10. Archive
|
||||
|
||||
- [ ] 10.1 Fold `specs/permissions/spec.md` into `openspec/specs/permissions/spec.md` (creating the cap file — no `permissions` cap exists in `openspec/specs` today)
|
||||
- [ ] 10.2 Move `openspec/changes/permissions-completion/` → `openspec/archive/<yyyy-mm-dd>-permissions-completion/`
|
||||
- [ ] 10.3 Refresh `MANIFEST.md` (no package set or dep changes expected, but rerun for safety)
|
||||
- [ ] 10.4 Update `STATUS.md` with the rolled-up gap closure for the four enumerated features
|
||||
|
||||
## Audit Note (2026-05-02)
|
||||
|
||||
Audit verdict: **DEFERRED — 0/74 tasks evidenced in code**.
|
||||
|
||||
Evidence reviewed:
|
||||
|
||||
- `src/index.ts` re-exports the existing surface (Ability, AbilityBuilder, PermissionManager, RoleManager, PolicyEngine, MemoryAdapter, role presets) but does NOT export `createPermissions`, `permissionEvents`, `deepGet`, `deepHas`, `matchPattern`, or `patternToRegex`.
|
||||
- `src/utils/` does not exist; no `pattern-match/` or `deep-get/` directories.
|
||||
- `src/presets/` contains only `role-presets/`; no `default-preset/` (and therefore no `createPermissions` factory + frozen `PermissionsInstance`).
|
||||
- `events/permission-events.ts` exports a type only (`PermissionEventMap`), not the `permissionEvents` singleton wired through `@suaveplan/events`.
|
||||
|
||||
Missing pieces:
|
||||
|
||||
1. `presets/default-preset/default-preset.ts` (`createPermissions` factory returning a frozen `PermissionsInstance`).
|
||||
2. `events/permission-events.ts` runtime export of the singleton `EventBusLike<PermissionEventMap>`, plus auto-wiring through PermissionManager / RoleManager / PolicyEngine.
|
||||
3. `utils/deep-get/` and `utils/pattern-match/` (with cache eviction at 1024 entries) and the integrations in `condition-matcher`, `field-matcher`, and `Ability.can` wildcard handling.
|
||||
|
||||
Decision: leave change open. The four feature blocks (factory, events singleton, deep-get, pattern-match) are independent additions, each with its own tests, docs, and OTel instruments — not a tick-and-archive job.
|
||||
Reference in New Issue
Block a user