npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hiennc24/permission-engine

v1.1.2

Published

Centralized fail-closed authorization engine for Biso24 Moleculer services

Downloads

388

Readme

@hiennc24/permission-engine

Centralized fail-closed authorization engine for Biso24 Moleculer services.

Battle-tested in api-gateway since 2026-Q1. One decision tree, one source of truth, LRU + single-flight cache, event-driven invalidation contract with svc-auth.

Why

  • Single decision tree — every route, every service, the same 13 branches (v1.1.0+).
  • Fail-closed on missing annotations — boot guard refuses to start if any route lacks a permission tag.
  • LRU + single-flight cache — concurrent same-key requests trigger one fetch.
  • Event-driven invalidationauth.permission.changed event evicts caches across the fleet (origin-checked, rate-limited).
  • DI-first — no hard imports of broker, auth, or i18n. Inject what you need.
  • /core subpath for downstream svc-* — pure helpers with zero runtime deps when full kit is overkill.

Install

yarn add @hiennc24/permission-engine

Peer deps (only required when using the event listener factory or BeforeActionCtx types):

yarn add moleculer

Usage — Full kit (api-gateway pattern)

import {
  PermissionEngine,
  createPermissionEventListener,
  buildActionPermissionMap,
  assertFullCoverage,
  type BeforeActionCtx,
  type BeforeActionRoute,
  type BeforeActionReq
} from '@hiennc24/permission-engine'
import { UnauthorizedError } from '@hiennc24/common'

// 1. Configure BEFORE creating the event listener so the first event has a
//    live cache to evict against.
PermissionEngine.configure({
  logger: broker.logger,
  fetchUserPermissions: async (userId, tenantId) => {
    const result = await broker.call(
      'svc-auth.authorizations.permissions.getUserPermissions',
      { params: { userId, tenantId } },
      { meta: { domain: tenantId } }
    )
    return Array.isArray(result) ? result : []
  },
  messageResolver: (key, meta) => getLocalizedMessage('AUTH', key, meta),
  errorFactory: (msg) => new UnauthorizedError(msg) // REQUIRED — see below
})

// 2. Subscribe to invalidation events from svc-auth.
broker.createService(
  createPermissionEventListener({
    invalidateUser: PermissionEngine.invalidateUser.bind(PermissionEngine),
    clearCache: PermissionEngine.clearCache.bind(PermissionEngine)
  })
)

// 3. Boot guard + initialize map.
assertFullCoverage(controllers, broker.logger)
const actionPermissionMap = buildActionPermissionMap(controllers, broker.logger)
PermissionEngine.initialize(actionPermissionMap)

// 4. Wire into moleculer-web AFTER initialize.
const beforeAction = async (
  ctx: BeforeActionCtx,
  route: BeforeActionRoute,
  req: BeforeActionReq,
  res: unknown
) => {
  const requirement = PermissionEngine.getRequirement(ctx, route, req)
  if (requirement !== 'unauthenticated') {
    await requiredAuth(ctx.meta) // your gateway-specific JWT verify
  }
  await PermissionEngine.run(ctx, route, req, res)
}

Usage — Lite (svc-* downstream, defense-in-depth)

For services that trust the gateway's authorization but want a local check before mutating state:

import { hasPermission, determineDataScope } from '@hiennc24/permission-engine/core'

// In a service action handler:
if (!hasPermission(ctx.meta.permissions ?? [], 'organization_service:employees:create')) {
  throw new Error('Defense-in-depth: gateway said yes but local check failed')
}
const scope = determineDataScope(ctx.meta.permissions ?? [])

/core trust boundary

/core ships pure helpers only — no cache, no event listener, no invalidation. Downstream consumers MUST observe:

  1. DO NOT cache the permissions array in service-local state. Reads must be per-request.
  2. DO NOT receive permissions via broker meta unless your broker enforces meta signing. hasPermission(ctx.meta.permissions, ...) is only safe when ctx.meta.permissions was populated by a trusted upstream (gateway's full kit after requiredAuth).
  3. If you need cache invalidation, upgrade to the full kit and wire createPermissionEventListener.

API Reference

PermissionEngine.configure(config: EngineConfig)

Boot-time DI. Throws TypeError if logger, fetchUserPermissions, or errorFactory missing.

| Field | Type | Required | Default | Notes | |---|---|---|---|---| | logger | EngineLogger | yes | — | { warn, error, info } shape | | fetchUserPermissions | (uid, tid) => Promise<string[]> | yes | — | Hits your auth service | | errorFactory | (msg) => Error | yes | — | No default. Inject a typed error (e.g. UnauthorizedError) so moleculer-web maps 401/403, not 500 | | messageResolver | (key, meta) => string | no | warn + key literal | For per-request i18n | | cacheTtlMs | number | no | 300_000 | 5 minutes | | cacheMax | number | no | 10_000 | LRU max entries | | fetchWaitMs | number | no | 100 | Single-flight wait window |

PermissionEngine.initialize(map, selfAccessMap?)

Sets the action-name → permission-tag map. Optional second arg wires the action-name → SelfAccessRule map for branch 6.5. Call after configure, before the first request.

PermissionEngine.initialize(
  buildActionPermissionMap(controllers, logger),
  buildActionSelfAccessMap(controllers, logger) // v1.1.0+
)

The single-arg call initialize(map) from 1.0.x still works — selfAccess is opt-in.

PermissionEngine.run(ctx, route, req, res): Promise<void>

Designed for moleculer-web beforeAction. Throws via errorFactory on denial. 13-branch decision tree (v1.1.0+) — see Decision Tree below.

PermissionEngine.getRequirement(ctx, route, req): RoutePermission | undefined

Single source of truth for action-name resolution. Use in your composedBeforeAction to decide whether to run requiredAuth.

PermissionEngine.invalidateUser(userId, tenantId?): number

Evicts userId:tenantId (with tenant) or all userId:* keys (without tenant). Returns count removed.

PermissionEngine.clearCache(): void

Drops all entries. Use for role-level invalidation or admin endpoints.

PermissionEngine.getCacheStats(): { size, max, ttlMs }

For health/metrics endpoints.

PermissionEngine.determineDataScope(perms): DataScope

Exact-segment match for system:* / *:all (→ 'all'), *:tenant / *:manage (→ 'tenant'), else 'own'.

buildActionPermissionMap(controllers, logger, opts?)

Aggregates serviceAction → permission across controllers. Throws on collision. With { strict: true } (default) also throws on missing tags — folds in assertFullCoverage semantics.

buildActionSelfAccessMap(controllers, logger) (v1.1.0+)

Aggregates serviceAction → SelfAccessRule across controllers. Routes without selfAccess are silently omitted (the feature is opt-in per route). Pass the result as the second argument to PermissionEngine.initialize.

getByPath(obj, dotPath): unknown (v1.1.0+)

Safe dot-path traversal — returns undefined for any missing segment, never throws. Used internally by branch 6.5 to resolve subjectPath against ctx.meta.user. Exported for reuse in downstream services.

assertFullCoverage(controllers, logger)

Standalone boot guard. Throws if any route has a serviceAction but no permission tag.

createPermissionEventListener({ invalidateUser, clearCache })

Returns a Moleculer ServiceSchema that subscribes to auth.permission.changed. Origin-checked (regex /^svc-auth(-|$)/), payload-validated, rate-limited.

hasPermission(userPerms, required): boolean

Exact match or qualified wildcard (app:* matches app:read). Lone * rejected.

hasAnyPermission(userPerms, required[]): boolean

OR semantics over hasPermission.

Decision Tree (PermissionEngine.run)

1.   requirement === 'unauthenticated'           → return (skip everything)
2.   !ctx.meta.user                              → throw via errorFactory (401-like)
3.   requirement === 'public'                    → return (auth ok, no permission check)
4.   requirement === undefined                   → log + throw (403 fail-closed; unmapped)
5.   ctx.meta.isAdmin                            → set dataScope='all', return
6.   !userId || !tenantId                        → log + throw (401-like)
6.5  selfAccess match (v1.1.0+)                  → set dataScope='self', permissions=[], return
7.   Array.isArray(ctx.meta.user.permissions)    → use inlined, NEVER persist to cache
8.   cache hit                                   → reuse
9.   fetch in-flight                             → wait fetchWaitMs, reuse
10.  fetch fresh                                 → fetchUserPermissions(uid, tid)
11.  !hasPermission(userPerms, requirement)      → log + throw (403)
12.  attach ctx.meta.permissions + dataScope     → return

Branch 6.5 — Self-access (v1.1.0+)

Routes can declare a structural ownership invariant via RouteDefinition.selfAccess:

{
  method: 'GET',
  path: '/employees/:id',
  serviceAction: 'svc-organization.employees.getDetail',
  permission: 'organization_service:employees:read',
  selfAccess: { paramKey: 'id', subjectPath: 'employeeId._id' }
}

At boot, buildActionSelfAccessMap collects every annotation. At runtime, branch 6.5 compares String(req.$params[paramKey]) against String(getByPath(ctx.meta.user, subjectPath)). On match:

  • ctx.meta.dataScope = 'self'
  • ctx.meta.permissions = []
  • no cache write, no fetchUserPermissions call

Branch ordering is load-bearing:

  • After branch 6 — a self-access match still needs tenantId so downstream services can apply tenant-scoped IDOR checks (engine compares structurally only — see below).
  • Before branch 7 — selfAccess wins over inlined-permission denial. A user who happens to lack the domain permission but is the resource owner still passes.

Branch 5 (admin) and branch 6 (missing identity) still take precedence.

Tenant isolation responsibility (v1.1.0+)

selfAccess is structural only. The engine does NOT validate that the matched record belongs to the caller's tenant — cross-tenant employeeId._id collision (different tenants happening to use the same ID) would pass branch 6.5 unchallenged.

Consumers MUST enforce tenant ownership in the downstream service. The reference pattern is svc-organization's verifyResourceOwnership(resource.orgIds, ctx.meta) middleware. If the downstream service skips this check, the self-access feature widens the IDOR surface.

Inlined permissions are NEVER persisted

If your auth service attaches permissions directly to ctx.meta.user (e.g. via JWT claim), the engine uses it for the current request but never writes to the LRU. This prevents a compromised auth path from poisoning shared cache. Trade-off: next request from the same user pays one fetchUserPermissions round-trip if no cache entry exists.

Permission naming convention

  • system:*dataScope = 'all'
  • *:all (terminal segment) → 'all'
  • *:tenant or *:manage (terminal segment) → 'tenant'
  • else → 'own'
  • Qualified wildcards like app:* are allowed.
  • Lone * is rejected — unqualified wildcards would grant unbounded authority.
  • Substring matches don't escalate (tenant_setting:read'own', not 'tenant').

Event contract with svc-auth

| Field | Value | |---|---| | Event name | auth.permission.changed (hardcoded) | | Origin nodeID | must match /(?:^|-)svc-auth(?:-|$)/ (regex hardcoded, v1.1.1+) | | Payload | { userId?, tenantId?, roleId?, reason? } |

Payload routing

Routing is reason-first, then ID-presence. Reason-based widening (v1.1.2+) handles bulk events where targeting IDs are null.

| Payload | Action | |---|---| | { reason: 'role-bulk-updated', ... } (v1.1.2+) | clearCache() regardless of IDs (rate-limited, shares 10/min budget with { roleId }) | | { userId, tenantId } | invalidateUser(userId, tenantId) | | { userId } (no tenantId) | warn insufficient scope, no eviction | | { tenantId } only (no other reason) | warn tenant-only invalidation not supported, no eviction | | { roleId } | clearCache() (rate-limited: max 10/minute) | | empty / null / non-object | warn, no eviction |

In production (NODE_ENV=production), events with null nodeID are also rejected (in-process emits allowed only in dev/test).

Node ID convention

TRUSTED_ORIGIN_REGEX = /(?:^|-)svc-auth(?:-|$)/ (v1.1.1+).

Accepted nodeIDs (examples):

  • svc-auth
  • svc-auth-1 / svc-auth-prod
  • biso24-svc-auth-mac-1234 — the Biso24 runtime convention from svc-auth/moleculer.config.ts (biso24-${pkg.name}${NODE_PREFIX_IDENTIFIER}-${hostname}-${pid})

Rejected:

  • svc-authfake — separator required after svc-auth
  • svcauth-1 — dash required before svc-auth
  • biso24-svc-organization-… — unrelated service
  • '', null, undefined — rejected in production; allowed in dev/test for in-process emits

Assumption: Biso24 deployments leave NODE_PREFIX_IDENTIFIER empty. If your environment sets it, ensure the value begins with a leading - (e.g. -staging) so the resulting nodeID still matches the regex. Otherwise the listener silently drops events and the cache-invalidation chain becomes dead code (the bug v1.1.1 fixes).

Migration guide

From 1.0.x to 1.1.0

  • initialize(map) single-arg call still works. Add the second arg only when you start annotating routes with selfAccess.
  • DataScope union widened with 'self'. If your code uses an exhaustive switch(dataScope) without a default, add a 'self' branch — treat it as the narrowest scope (the caller's own record only) and apply a server-side field whitelist on writes/reads.
  • Routes without selfAccess keep the 12-branch behavior. There is no behavior change for them.

From local PermissionEngine (api-gateway pattern)

See the apis/api-gateway Phase 6 migration commit for a full diff. Key changes:

  1. Replace local PermissionEngine import → package import.
  2. Add PermissionEngine.configure({...}) at boot — inject errorFactory.
  3. Move requiredAuth OUT of engine into your composedBeforeAction wrapper.
  4. Build composedBeforeAction AFTER initialize(), not at constructor time.
  5. Swap new PermissionEventListener(broker) for createPermissionEventListener({...}).

Sibling APIs (api-hcm, api-hrm, etc.)

Not a drop-in. Sibling APIs may have divergent hasPermission semantics (dot-namespacing, lone * acceptance) — a permission corpus audit is required before migration. See the plan's Out-of-Scope section.

Versioning

  • v1.x — stable contract following semver. Breaking changes bump major.

License

ISC