100 lines
14 KiB
Markdown
100 lines
14 KiB
Markdown
# 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.
|