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

@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.

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 allow policy ⇒ denied. No exceptions.
  • Deny overrides allow. A matching deny policy always wins, regardless of any allow policy.
  • 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, or constructor.

Install

npm install @itsmaazmalick/dynamic-rbac

Quick 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" },
});
// => true

See 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' action patterns (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 any conditions that inspect the resource (see the callout below), since there's no real object behind the identifier.
  • { user, resource: article } — the full context those policies' conditions read from, e.g. a deny rule checking resource.status === "published" needs the actual article object, 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's conditions reference resource fields (ownership, status, tenant) and the guard hasn't loaded the record, those fields resolve to undefined and the condition simply won't match — including deny conditions, 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-level can() 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):

  1. Filter all policies whose action/resource match the request.
  2. Evaluate matching deny policies' conditions. Any match ⇒ return false immediately — allow policies are not consulted.
  3. Evaluate matching allow policies' conditions. Any match ⇒ return true.
  4. 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 enabled

Caching 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 (or context.subject.id). If your context shapes identity another way (e.g. context.session.userId) and you enable caching without overriding cacheKeyResolver, 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 explicit cacheKeyResolver that 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_id

Raw 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