Files
2026-05-27 23:29:53 +01:00

14 KiB

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.