@hiennc24/permission-engine
v1.1.2
Published
Centralized fail-closed authorization engine for Biso24 Moleculer services
Downloads
388
Maintainers
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 invalidation —
auth.permission.changedevent evicts caches across the fleet (origin-checked, rate-limited). - DI-first — no hard imports of broker, auth, or i18n. Inject what you need.
/coresubpath for downstreamsvc-*— pure helpers with zero runtime deps when full kit is overkill.
Install
yarn add @hiennc24/permission-enginePeer deps (only required when using the event listener factory or BeforeActionCtx types):
yarn add moleculerUsage — 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:
- DO NOT cache the permissions array in service-local state. Reads must be per-request.
- DO NOT receive permissions via broker meta unless your broker enforces meta signing.
hasPermission(ctx.meta.permissions, ...)is only safe whenctx.meta.permissionswas populated by a trusted upstream (gateway's full kit afterrequiredAuth). - 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 → returnBranch 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
fetchUserPermissionscall
Branch ordering is load-bearing:
- After branch 6 — a self-access match still needs
tenantIdso 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'*:tenantor*: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-authsvc-auth-1/svc-auth-prodbiso24-svc-auth-mac-1234— the Biso24 runtime convention fromsvc-auth/moleculer.config.ts(biso24-${pkg.name}${NODE_PREFIX_IDENTIFIER}-${hostname}-${pid})
Rejected:
svc-authfake— separator required aftersvc-authsvcauth-1— dash required beforesvc-authbiso24-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 withselfAccess.DataScopeunion widened with'self'. If your code uses an exhaustiveswitch(dataScope)without adefault, 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
selfAccesskeep 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:
- Replace local
PermissionEngineimport → package import. - Add
PermissionEngine.configure({...})at boot — injecterrorFactory. - Move
requiredAuthOUT of engine into yourcomposedBeforeActionwrapper. - Build
composedBeforeActionAFTERinitialize(), not at constructor time. - Swap
new PermissionEventListener(broker)forcreatePermissionEventListener({...}).
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
