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

pgrls

v0.1.0

Published

Postgres row-level security you can actually verify. Policies as TypeScript, tenant context as a transaction, and one test that proves no table is exposed.

Readme

pgrls

Postgres row-level security you can actually verify. Policies declared beside the table, tenant context that can't leak across a connection pool, and one test that asks Postgres whether every table is really protected — and names the ones that aren't.

CI npm license

test("every table is protected", async () => {
  await assertFullRlsCoverage(client);
});
pgrls: 3 of 14 tables are not protected.

  ✗ public.audit_log      row-level security is not enabled — every row is readable by any role
  ✗ public.webhook_event  RLS is enabled but not FORCED — the table owner bypasses it, and most
                          applications connect as the owner of their own tables
  ✗ public.session        RLS is enabled with no policy — every query against this table returns nothing

Run that against a multi-tenant database you didn't write and see what comes back.

Why this exists

RLS is the right answer for tenant isolation: the database enforces it, so a forgotten WHERE org_id = $1 stops being a data breach. The problem is that RLS has three failure modes that all look like success, and none of them show up in your tests, your types, or your code review.

1. The owner bypasses it. ENABLE ROW LEVEL SECURITY does nothing to the table's owner. Most applications connect as the role that owns their tables, so the policy is real, the predicate is correct, and it protects nothing at all. FORCE ROW LEVEL SECURITY is what closes it, and pgrls turns it on by default.

2. Superusers bypass it entirely. No policy, and no amount of FORCE, applies to a superuser or a role with BYPASSRLS. This is why the coverage audit checks who it is running as and refuses to return a green report over such a connection — a passing audit gathered as a superuser is not evidence of anything, and silently handing one back would be the most dangerous thing this library could do.

3. An unset tenant can raise instead of denying. The obvious predicate is org_id = current_setting('app.tenant', true)::uuid. Once a custom setting has been set inside a transaction and that transaction ends, it does not vanish — it reverts to the empty string. ''::uuid raises invalid input syntax for type uuid: "", so on a pooled connection every request after the first one crashes instead of returning nothing. pgrls wraps the setting in nullif(…, ''), so an unscoped query sees an empty table, which is the only safe answer.

None of this is exotic. All of it is easy to get wrong once and never notice.

Install

npm install pgrls

drizzle-orm is an optional peer dependency, needed only for pgrls/drizzle.

The three pieces

Policies live beside the table

import { pgTable, uuid, integer } from "drizzle-orm/pg-core";
import { rls } from "pgrls/drizzle";

export const invoice = pgTable("invoice", {
  id: uuid().primaryKey().defaultRandom(),
  orgId: uuid("org_id").notNull(),
  total: integer().notNull(),
});

export const invoicePolicy = rls(invoice, { tenantColumn: "org_id" });

policySql(invoicePolicy) gives you the statements to paste into a migration. They are idempotent — the policy is dropped and recreated, because CREATE POLICY has no OR REPLACE and a re-run migration must actually apply a changed predicate.

Not using Drizzle? definePolicy("invoice", { tenantColumn: "org_id" }) takes a plain table name. Nothing in the core depends on an ORM.

Tenant context is a transaction

import { withTenant } from "pgrls";

const invoices = await withTenant(client, orgId, async (tx) => {
  const { rows } = await tx.query("SELECT * FROM invoice");
  return rows;
});

The tenant is written with set_config(name, value, true). The third argument makes it local to the transaction, so it cannot outlive the request that set it — on a pooled connection a session-level setting is inherited by whoever borrows that connection next, which is the worst bug this library could have. There is deliberately no API for setting a tenant outside a transaction.

Using a function call rather than SET LOCAL also means the tenant id travels as a bind parameter instead of being interpolated into SQL.

One test that proves it

import { assertFullRlsCoverage } from "pgrls";

test("every table is protected", async () => {
  await assertFullRlsCoverage(client, {
    exclude: ["__drizzle_migrations"],
  });
});

It asks pg_class and pg_policy directly, so it sees what actually shipped — including the table someone added in a migration last Tuesday and forgot to write a policy for. That is the one that matters, and it is exactly the one a schema-file linter misses.

Run it as the role your application connects with, not as your migration superuser. The assertion will tell you if you get that wrong.

API

| Export | What it does | | ----------------------------------------- | ------------------------------------------------------ | | assertFullRlsCoverage(client, opts?) | Throws RlsCoverageError if any table is unprotected. | | rlsCoverage(client, opts?) | The same audit as data, without throwing. | | auditRole(client) | Who the connection is, and whether it bypasses RLS. | | withTenant(client, id, fn, opts?) | Runs fn in a transaction scoped to a tenant. | | currentTenant(client, opts?) | The tenant Postgres currently sees, or null. | | definePolicy(table, opts) | Resolves a policy spec, applying defaults. | | policySql(spec) / dropPolicySql(spec) | The statements, for your migration. | | applyPolicy(client, spec) | Runs them, in one transaction. | | rls(table, opts) — from pgrls/drizzle | definePolicy for a Drizzle table. |

Coverage options: schemas (default ["public"]), exclude (names or schema.name), allowUnforced, allowBypassingRole. The last two exist so you can opt out deliberately; both default to the strict reading.

Any driver

The core needs one method:

interface SqlExecutor {
  query<T>(text: string, params?: readonly unknown[]): Promise<{ rows: T[] }>;
}

That is pg's shape. postgres.js, slonik and a Drizzle session each wrap to it in a few lines. This is deliberate — the tooling that exists today is tied to one hosting provider, and tenant isolation shouldn't be.

What it deliberately does not do

  • It does not manage your migrations. It emits SQL; where that SQL goes is your migration tool's business.
  • It does not invent a policy language. One shape — a tenant column compared against a session setting — covers the overwhelming majority of multi-tenant applications. Anything more complicated is a hand-written policy, and assertFullRlsCoverage will still count it.
  • It does not read your schema file. Every claim it makes comes from asking the running database.

Requirements

Postgres 9.5 or newer, where row-level security landed. Node 22.13 or newer. CI runs the full suite against Postgres 14, 15, 16, 17 and 18 — those are the versions actually verified.

License

MIT © Rajan Aggarwal