@itsmaazmalick/dynamic-rbac
v0.1.0
Published
Zero-dependency, framework-agnostic Dynamic RBAC/ABAC policy engine for TypeScript. Runs anywhere JS runs, including Next.js Edge Middleware.
Maintainers
Readme
@itsmaazmalick/dynamic-rbac
A zero-dependency, framework-agnostic Dynamic RBAC/ABAC policy engine for TypeScript. It evaluates access-control policies you supply at runtime — it does not store, fetch, or cache them for you beyond an optional in-memory TTL cache.
- Zero runtime dependencies. Small enough to run in Next.js Edge Middleware, Cloudflare Workers, or any V8 isolate, as well as standard Node.js backends.
- Inversion of control. You implement a
PolicyProvider(Prisma, Redis, a static file — anything). The engine only evaluates. - Fail-safe by default. No matching
allowpolicy ⇒ denied. No exceptions. - Deny overrides allow. A matching
denypolicy always wins, regardless of anyallowpolicy. - No
eval. Wildcard matching and condition evaluation are done with safe, hand-written parsing — no dynamic code execution. - Prototype-pollution safe. Nested context paths (
"user.tenantId") are resolved without ever touching__proto__,prototype, orconstructor.
Install
npm install @itsmaazmalick/dynamic-rbacQuick start
import { RbacEngine, type Policy, type PolicyProvider } from "@itsmaazmalick/dynamic-rbac";
// 1. Implement a PolicyProvider — this example uses a static array, but it
// could just as easily query Prisma, Redis, or a remote config service.
class MyPolicyProvider implements PolicyProvider {
async getPolicies(): Promise<Policy[]> {
return [
{
id: "editors-manage-own-tenant-articles",
effect: "allow",
action: "article:*",
resource: "article:*",
conditions: [{ field: "user.tenantId", operator: "equals", value: "$resource.tenantId" }],
},
{
id: "block-delete-when-published",
effect: "deny",
action: "article:delete",
resource: "article:*",
conditions: [{ field: "resource.status", operator: "equals", value: "published" }],
},
];
}
}
// 2. Wire it into the engine.
const rbac = new RbacEngine(new MyPolicyProvider());
// 3. Check permissions with a context object.
const allowed = await rbac.can("article:update", "article:123", {
user: { id: "u_1", role: "editor", tenantId: "tenant_acme" },
resource: { tenantId: "tenant_acme", status: "draft" },
});
// => trueSee examples/basic-usage.ts for a runnable version with a mocked async data source and multiple scenarios (allow, deny-override, condition mismatch).
Policy schema
interface Policy {
id?: string;
effect: "allow" | "deny";
action: string; // supports wildcards: "article:*", "*"
resource: string; // supports wildcards: "article:*", "*"
conditions?: ConditionRule[]; // ANDed together; omit for an unconditional policy
}
interface ConditionRule {
field: string; // dot path into the context, e.g. "user.tenantId"
operator: ConditionOperator;
value: unknown; // literal, or "$some.path" to reference another context field
}Wildcard matching
action and resource are matched with glob-style *:
| Pattern | Matches |
| --------------- | --------------------------------- |
| * | anything |
| article:* | article:read, article:delete, … |
| article:read | only itself |
Action & resource naming convention
The library treats action and resource as opaque strings — it has no built-in notion of "resource types." The type:verb / type:id convention used throughout this README is just a convention, but it's the one to use, because it's what makes wildcards useful:
action→"<resourceType>:<verb>", e.g."article:read","article:delete","billing:refund".resource→"<resourceType>:<instanceId>"for a specific record, e.g."article:clx1a2b3". For actions that don't operate on an existing record yet ("article:create"), a type-level string like"article"is fine — there's no instance to identify.
article:* only has meaning inside a stored policy — it's the pattern half of the match. matchesPattern(pattern, value) expands * in the policy's action/resource, then checks whether the concrete value you passed to can() fits that pattern. A policy of { action: "article:*", resource: "article:*" } matches a call to can("article:delete", "article:clx1a2b3", ...) — the wildcard side is the policy, not the call.
Concretely, for the snippet from the Next.js example:
rbac.can("article:delete", `article:${article.id}`, { user, resource: article });"article:delete"— the exact action being attempted. Not a pattern; it's compared against policies'actionpatterns (which may themselves contain*).`article:${article.id}`— the exact resource instance being acted on, e.g."article:clx1a2b3". Always pass the concrete id here, not"article:*"— the engine never expands*on this side, so a literal"article:*"would only happen to match policies whose pattern is also wildcarded, and it silently breaks anyconditionsthat inspect the resource (see the callout below), since there's no real object behind the identifier.{ user, resource: article }— the full context those policies'conditionsread from, e.g. a deny rule checkingresource.status === "published"needs the actualarticleobject, not just its id.
Coarse vs. authoritative checks. A route-level guard (see the NestJS example below) sometimes only has a decorator's static metadata to work with — no loaded entity yet — so it's reasonable for it to run a type-level check (
resource: "article:*") as a cheap first pass. But that check can only be as precise as the context it's given: if a policy'sconditionsreference resource fields (ownership, status, tenant) and the guard hasn't loaded the record, those fields resolve toundefinedand the condition simply won't match — includingdenyconditions, which means the coarse check can pass when the authoritative one would have blocked it. Treat a metadata-only guard as defense-in-depth, and always run the concrete, instance-levelcan()check — with the full resource loaded into context — as the actual gate before a mutation.
Condition operators
| Operator | Semantics |
| ---------------------- | ------------------------------------------- |
| equals / notEquals | strict === / !== |
| greaterThan / lessThan / greaterThanOrEqual / lessThanOrEqual | numeric/string ordering |
| in / notIn | membership in an array value |
| contains | substring match (strings) or membership (arrays) |
Referencing another context field instead of a literal: prefix value with $, e.g. { field: "resource.ownerId", operator: "equals", value: "$user.id" } asserts the resource is owned by the current user.
Evaluation order
engine.can(action, resource, context):
- Filter all policies whose
action/resourcematch the request. - Evaluate matching
denypolicies' conditions. Any match ⇒ returnfalseimmediately — allow policies are not consulted. - Evaluate matching
allowpolicies' conditions. Any match ⇒ returntrue. - Otherwise, return
false(deny by default).
Caching (optional)
For hot paths — e.g. Edge Middleware evaluating the same subject across many requests — you can enable a short-lived in-memory cache of each subject's policy set:
const rbac = new RbacEngine(provider, {
cacheTtlMs: 30_000,
// Optional: override how the cache key is derived from context.
// Defaults to context.user.id, falling back to context.subject.id.
cacheKeyResolver: (context) => (context.user as { id: string }).id,
// Optional: cap on distinct cache entries; oldest is evicted (FIFO) past this. Default 10,000.
maxCacheEntries: 10_000,
});
rbac.clearCache(); // call after mutating policies, if caching is enabledCaching is disabled by default (cacheTtlMs: 0) — every can() call hits your provider.
Cache-key correctness matters — get this wrong and one subject's policies can be served to another. The default resolver reads
context.user.id(orcontext.subject.id). If your context shapes identity another way (e.g.context.session.userId) and you enable caching without overridingcacheKeyResolver, every caller that lacks a resolvable id falls back to a fresh, never-reused key — caching simply won't help for that shape of context, but it will never coalesce two different subjects onto the same entry. Once you enable caching, always pass an explicitcacheKeyResolverthat matches your actual context shape rather than relying on the default guess.That same "fresh key per call" fallback is also why the cache is bounded (
maxCacheEntries, default 10,000): traffic that never resolves to a stable key — unauthenticated requests, or any context shape the resolver doesn't recognize — would otherwise grow the cache forever. Once at capacity, the oldest entry is evicted to make room for the newest.
Persisting policies (database schema)
The library has no opinion on storage — Policy[] is just data. The schema below is the one referenced by the framework examples further down: roles are dynamic, tenant-scoped rows (not a hardcoded enum), each role owns a set of policies, and users are assigned to roles per tenant. That's what makes this "dynamic" RBAC — an admin can create a new role and attach policies to it at runtime, with no code deploy.
tenants (implicit — every row below carries tenant_id directly, no separate table required)
roles
├── id PK
├── tenant_id
├── name e.g. "editor", "billing-admin"
└── created_at
policies
├── id PK
├── tenant_id
├── role_id FK → roles.id
├── effect "allow" | "deny"
├── action e.g. "article:*"
├── resource e.g. "article:*"
├── conditions JSON, nullable e.g. [{ field, operator, value }, ...]
└── created_at
user_roles (many-to-many: a user can hold several roles)
├── user_id
├── role_id FK → roles.id
└── tenant_idRaw SQL (PostgreSQL)
CREATE TYPE policy_effect AS ENUM ('allow', 'deny');
CREATE TABLE roles (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, name)
);
CREATE TABLE policies (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
effect policy_effect NOT NULL,
action TEXT NOT NULL,
resource TEXT NOT NULL,
conditions JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX policies_tenant_role_idx ON policies (tenant_id, role_id);
CREATE TABLE user_roles (
user_id TEXT NOT NULL,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
tenant_id TEXT NOT NULL,
PRIMARY KEY (user_id, role_id)
);
CREATE INDEX user_roles_tenant_user_idx ON user_roles (tenant_id, user_id);Prisma schema
// schema.prisma
enum PolicyEffect {
allow
deny
}
model Role {
id String @id @default(cuid())
tenantId String
name String
createdAt DateTime @default(now())
policies Policy[]
users UserRole[]
@@unique([tenantId, name])
@@map("roles")
}
model Policy {
id String @id @default(cuid())
tenantId String
roleId String
effect PolicyEffect
action String
resource String
conditions Json?
createdAt DateTime @default(now())
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@index([tenantId, roleId])
@@map("policies")
}
model UserRole {
userId String
roleId String
tenantId String
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@id([userId, roleId])
@@index([tenantId, userId])
@@map("user_roles")
}// prisma-policy.provider.ts
import type { PrismaClient } from "@prisma/client";
import type { ConditionRule, EvaluationContext, Policy, PolicyProvider } from "@itsmaazmalick/dynamic-rbac";
export class PrismaPolicyProvider implements PolicyProvider {
constructor(private readonly db: PrismaClient) {}
async getPolicies(context: EvaluationContext): Promise<Policy[]> {
const user = context.user as { id: string; tenantId: string };
const rows = await this.db.policy.findMany({
where: {
tenantId: user.tenantId,
role: { users: { some: { userId: user.id } } },
},
});
return rows.map((row) => ({
id: row.id,
effect: row.effect, // "allow" | "deny" — Prisma enum values line up with the library's Effect type
action: row.action,
resource: row.resource,
conditions: (row.conditions as ConditionRule[] | null) ?? undefined,
}));
}
}Drizzle schema
// schema.ts
import { pgEnum, pgTable, text, jsonb, timestamp, primaryKey, uniqueIndex, index } from "drizzle-orm/pg-core";
import type { ConditionRule } from "@itsmaazmalick/dynamic-rbac";
export const policyEffect = pgEnum("policy_effect", ["allow", "deny"]);
export const roles = pgTable(
"roles",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
tenantId: text("tenant_id").notNull(),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [uniqueIndex("roles_tenant_name_unique").on(table.tenantId, table.name)],
);
export const policies = pgTable(
"policies",
{
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
tenantId: text("tenant_id").notNull(),
roleId: text("role_id")
.notNull()
.references(() => roles.id, { onDelete: "cascade" }),
effect: policyEffect("effect").notNull(),
action: text("action").notNull(),
resource: text("resource").notNull(),
conditions: jsonb("conditions").$type<ConditionRule[]>(),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => [index("policies_tenant_role_idx").on(table.tenantId, table.roleId)],
);
export const userRoles = pgTable(
"user_roles",
{
userId: text("user_id").notNull(),
roleId: text("role_id")
.notNull()
.references(() => roles.id, { onDelete: "cascade" }),
tenantId: text("tenant_id").notNull(),
},
(table) => [
primaryKey({ columns: [table.userId, table.roleId] }),
index("user_roles_tenant_user_idx").on(table.tenantId, table.userId),
],
);// drizzle-policy.provider.ts
import { and, eq } from "drizzle-orm";
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import type { EvaluationContext, Policy, PolicyProvider } from "@itsmaazmalick/dynamic-rbac";
import { policies, userRoles } from "./schema";
export class DrizzlePolicyProvider implements PolicyProvider {
constructor(private readonly db: NodePgDatabase) {}
async getPolicies(context: EvaluationContext): Promise<Policy[]> {
const user = context.user as { id: string; tenantId: string };
const rows = await this.db
.select({
id: policies.id,
effect: policies.effect,
action: policies.action,
resource: policies.resource,
conditions: policies.conditions,
})
.from(policies)
.innerJoin(userRoles, eq(userRoles.roleId, policies.roleId))
.where(and(eq(policies.tenantId, user.tenantId), eq(userRoles.userId, user.id)));
return rows.map((row) => ({ ...row, conditions: row.conditions ?? undefined }));
}
}Framework integrations
The engine itself never imports a framework — every integration below is just "build an EvaluationContext from the request, call engine.can(), act on the boolean." The pattern is identical across backends; only how you read the request and how you reject differ.
In every example, define the engine once as a singleton and import it wherever it's needed — never construct a new RbacEngine per request.
// lib/rbac.ts — shared by every backend example below
import { RbacEngine } from "@itsmaazmalick/dynamic-rbac";
import { PrismaPolicyProvider } from "./prisma-policy.provider";
import { prisma } from "./prisma";
export const rbac = new RbacEngine(new PrismaPolicyProvider(prisma), { cacheTtlMs: 10_000 });Next.js (App Router)
Edge Middleware — route-level gating before a request ever reaches a page or handler. The engine has no Node-only APIs, so it runs unmodified under the Edge runtime:
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { rbac } from "./lib/rbac";
export async function middleware(request: NextRequest) {
const session = await getSessionFromCookie(request); // your own session lookup
const allowed = await rbac.can("dashboard:view", "dashboard:main", { user: session.user });
if (!allowed) {
return NextResponse.redirect(new URL("/403", request.url));
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*"] };Route Handlers / Server Actions — resource-level checks that need the specific record, not just the route:
// app/api/articles/[id]/route.ts
import { NextResponse } from "next/server";
import { rbac } from "@/lib/rbac";
import { getCurrentUser } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function DELETE(request: Request, { params }: { params: { id: string } }) {
const user = await getCurrentUser();
const article = await prisma.article.findUniqueOrThrow({ where: { id: params.id } });
const allowed = await rbac.can("article:delete", `article:${article.id}`, { user, resource: article });
if (!allowed) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await prisma.article.delete({ where: { id: params.id } });
return NextResponse.json({ ok: true });
}NestJS + Prisma
A Guard + decorator pair is the idiomatic way to attach permission checks to controller routes, backed by the engine registered as a DI provider.
// rbac/rbac.constants.ts
export const RBAC_ENGINE = Symbol("RBAC_ENGINE");
export const PERMISSION_KEY = "rbac:permission";
// rbac/require-permission.decorator.ts
import { SetMetadata } from "@nestjs/common";
export interface RequiredPermission {
action: string;
resource: string;
}
export const RequirePermission = (action: string, resource: string) =>
SetMetadata(PERMISSION_KEY, { action, resource } satisfies RequiredPermission);
// rbac/rbac.guard.ts
import { CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { RbacEngine } from "@itsmaazmalick/dynamic-rbac";
import { PERMISSION_KEY, RBAC_ENGINE, type RequiredPermission } from "./rbac.constants";
@Injectable()
export class RbacGuard implements CanActivate {
constructor(
@Inject(RBAC_ENGINE) private readonly rbac: RbacEngine,
private readonly reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.get<RequiredPermission>(PERMISSION_KEY, context.getHandler());
if (!required) {
return true; // route has no @RequirePermission — not guarded by RBAC
}
const request = context.switchToHttp().getRequest();
return this.rbac.can(required.action, required.resource, {
user: request.user, // populated by your auth guard/strategy upstream
resource: request.params,
});
}
}
// rbac/rbac.module.ts
import { Global, Module } from "@nestjs/common";
import { RbacEngine } from "@itsmaazmalick/dynamic-rbac";
import { PrismaService } from "../prisma/prisma.service";
import { PrismaPolicyProvider } from "./prisma-policy.provider";
import { RBAC_ENGINE } from "./rbac.constants";
import { RbacGuard } from "./rbac.guard";
@Global()
@Module({
providers: [
{
provide: RBAC_ENGINE,
useFactory: (prisma: PrismaService) => new RbacEngine(new PrismaPolicyProvider(prisma), { cacheTtlMs: 10_000 }),
inject: [PrismaService],
},
RbacGuard,
],
exports: [RBAC_ENGINE, RbacGuard],
})
export class RbacModule {}Usage on a controller — run RbacGuard after your authentication guard, since it reads request.user. Note the decorator's "article:*" is deliberately a type-level pattern: the guard runs before the handler, so there's no loaded Article yet — see the coarse-vs-authoritative callout above.
@Controller("articles")
export class ArticlesController {
@Delete(":id")
@UseGuards(AuthGuard, RbacGuard)
@RequirePermission("article:delete", "article:*")
remove(@Param("id") id: string) {
return this.articlesService.remove(id, /* current user */);
}
}The guard is a cheap first pass, not the authoritative check — it can't evaluate conditions that inspect the resource (ownership, status, tenant) because it never loaded the record. Run the concrete, instance-level check in the service once the entity is in hand:
// articles.service.ts
import { ForbiddenException, Inject, Injectable } from "@nestjs/common";
import type { RbacEngine } from "@itsmaazmalick/dynamic-rbac";
import { RBAC_ENGINE } from "./rbac/rbac.constants";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class ArticlesService {
constructor(
@Inject(RBAC_ENGINE) private readonly rbac: RbacEngine,
private readonly prisma: PrismaService,
) {}
async remove(id: string, user: AuthUser) {
const article = await this.prisma.article.findUniqueOrThrow({ where: { id } });
const allowed = await this.rbac.can("article:delete", `article:${article.id}`, { user, resource: article });
if (!allowed) {
throw new ForbiddenException();
}
return this.prisma.article.delete({ where: { id } });
}
}Express.js
A middleware factory that closes over the action and a resource resolver:
// middleware/requirePermission.ts
import type { Request, Response, NextFunction } from "express";
import { rbac } from "../lib/rbac";
export function requirePermission(action: string, resolveResource: (req: Request) => string) {
return async (req: Request, res: Response, next: NextFunction) => {
const allowed = await rbac.can(action, resolveResource(req), {
user: req.user, // populated by your auth middleware upstream
resource: req.params,
});
if (!allowed) {
return res.status(403).json({ error: "Forbidden" });
}
next();
};
}
// routes/articles.ts — resolveResource returns the concrete instance id being acted on
router.delete(
"/articles/:id",
requirePermission("article:delete", (req) => `article:${req.params.id}`),
deleteArticleHandler,
);As with the NestJS guard, this middleware only has req.params — if your article:delete policies carry conditions on resource fields (ownership, status), load the full record and run an authoritative rbac.can() check with resource: article inside deleteArticleHandler before deleting.
Other Node.js servers (Fastify, Koa, Hono, …)
Same shape as Express — a preHandler/hook that builds context and calls rbac.can(). Fastify example:
fastify.delete("/articles/:id", {
preHandler: async (request, reply) => {
const allowed = await rbac.can("article:delete", `article:${request.params.id}`, { user: request.user });
if (!allowed) {
return reply.code(403).send({ error: "Forbidden" });
}
},
}, deleteArticleHandler);Client-side usage (React, Vue) — UX only, not a security boundary
The client is never the enforcement point. Every example above (Next.js Route Handlers, NestJS, Express, Fastify) must independently authorize the request server-side — a client-side
can()check only controls what's rendered, and a user can trivially bypass it. Use it to hide buttons and routes a user can't use, never as the only gate in front of a mutation.
A minimal browser-side provider fetches the current session's already-resolved policy set once and lets the engine evaluate locally from then on:
// lib/rbacClient.ts
import { RbacEngine, type Policy, type PolicyProvider } from "@itsmaazmalick/dynamic-rbac";
class BrowserPolicyProvider implements PolicyProvider {
private cached: Policy[] | null = null;
async getPolicies(): Promise<Policy[]> {
if (!this.cached) {
const res = await fetch("/api/me/policies", { credentials: "include" });
this.cached = (await res.json()) as Policy[];
}
return this.cached;
}
}
export const rbacClient = new RbacEngine(new BrowserPolicyProvider());React:
// usePermission.ts
import { useEffect, useState } from "react";
import type { EvaluationContext } from "@itsmaazmalick/dynamic-rbac";
import { rbacClient } from "./lib/rbacClient";
export function usePermission(action: string, resource: string, context: EvaluationContext) {
const [state, setState] = useState({ loading: true, allowed: false });
useEffect(() => {
let cancelled = false;
setState({ loading: true, allowed: false });
rbacClient.can(action, resource, context).then((allowed) => {
if (!cancelled) setState({ loading: false, allowed });
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [action, resource, JSON.stringify(context)]);
return state;
}
function DeleteArticleButton({ article, user }: { article: Article; user: User }) {
const { loading, allowed } = usePermission("article:delete", `article:${article.id}`, { user, resource: article });
if (loading || !allowed) return null;
return <button onClick={() => deleteArticle(article.id)}>Delete</button>;
}Vue 3 (Composition API):
// usePermission.ts
import { ref, watchEffect } from "vue";
import type { EvaluationContext } from "@itsmaazmalick/dynamic-rbac";
import { rbacClient } from "./lib/rbacClient";
export function usePermission(action: string, resource: string, context: EvaluationContext) {
const loading = ref(true);
const allowed = ref(false);
watchEffect(async () => {
loading.value = true;
allowed.value = await rbacClient.can(action, resource, context);
loading.value = false;
});
return { loading, allowed };
}<script setup lang="ts">
import { usePermission } from "./usePermission";
const props = defineProps<{ article: Article; user: User }>();
const { loading, allowed } = usePermission("article:delete", `article:${props.article.id}`, {
user: props.user,
resource: props.article,
});
</script>
<template>
<button v-if="!loading && allowed" @click="$emit('delete', article.id)">Delete</button>
</template>API reference
class RbacEngine(provider: PolicyProvider, options?: RbacEngineOptions)can(action: string, resource: string, context?: EvaluationContext): Promise<boolean>clearCache(): void
interface PolicyProvider { getPolicies(context: EvaluationContext): Promise<Policy[]> | Policy[] }matchesPattern(pattern: string, value: string): boolean— the wildcard matcher, exported for testing your own policies.evaluateCondition/evaluateConditions/resolvePath— the ABAC condition evaluator, exported for standalone use or testing.
Development
npm install
npm run typecheck
npm run test # node:test — 95 unit/integration/security/e2e cases, zero test-framework dependency
npm run example # runs examples/basic-usage.ts
npm run build # emits dist/ (ESM + CJS + .d.ts)The test suite (test/) covers condition evaluation and wildcard matching in isolation, RbacEngine.can()'s evaluation order and caching, a full multi-tenant SaaS scenario against mock role/policy/user data, and a dedicated security suite (prototype-pollution resistance, ReDoS resistance, fail-safe behavior under malformed policy data).
License
MIT
