diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml
index 7d7bd52b1..c4e095020 100644
--- a/.github/workflows/run-tests.yaml
+++ b/.github/workflows/run-tests.yaml
@@ -87,6 +87,8 @@ jobs:
env: {}
- package: graphql/playwright-test
env: {}
+ - package: packages/safegres
+ env: {}
# - package: jobs/knative-job-worker
# env: {}
- package: packages/csv-to-pg
diff --git a/packages/safegres/README.md b/packages/safegres/README.md
new file mode 100644
index 000000000..274f54441
--- /dev/null
+++ b/packages/safegres/README.md
@@ -0,0 +1,55 @@
+
+
+
+
+# safegres
+
+Pure-Postgres Row-Level Security auditor. No app framework required. Drop it on any PostgreSQL database and get a structured report on grants, RLS enforcement, policy coverage, and risky SQL policy patterns.
+
+safegres audits Row-Level Security from inside Postgres. It checks whether tables with grants are protected by RLS, whether policies actually cover the granted operations, and whether policy bodies contain risky patterns like permissive `true` checks, volatile functions, or role/session-based bypass logic.
+
+```bash
+npm install -g safegres
+
+# Standard libpq env vars (PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE)
+export PGHOST=localhost PGUSER=postgres PGPASSWORD=password PGDATABASE=mydb
+safegres audit
+```
+
+Per-field overrides (`--host`, `--port`, `--user`, `--password`, `--database`) and a full `--connection ` flag are also supported. See `safegres audit --help`.
+
+## What it checks
+
+| Code | Severity | Category | Check |
+| --- | --- | --- | --- |
+| A1 | critical | flags | RLS enabled but **0 policies** (effectively deny-all) |
+| A2 | high | flags | Grants exist on a table with **RLS disabled** |
+| A3 | medium | flags | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table owner bypass) |
+| A4 | high | coverage | INSERT / UPDATE / DELETE grant with **no covering policy** for that verb |
+| A5 | medium | coverage | SELECT grant with **no policy** (silent empty result) |
+| A6 | info | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
+| A7 | high | anti-pattern | Trivially-permissive policy (`USING (true)` / `WITH CHECK (true)`) |
+| P1 | high | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) |
+| P5 | high | anti-pattern | Policy body references **`session_user`** / `current_user` / `pg_has_role(...)` |
+
+Coverage is aggregated `(table, role) → { hasUsing, hasWithCheck }` across every applicable permissive policy (FOR ALL + PUBLIC-role policies considered). Roles with `BYPASSRLS` are suppressed.
+
+## Library use
+
+```ts
+import { Client } from 'pg';
+import { getPgEnvOptions } from 'pg-env';
+import { audit, renderPretty } from 'safegres';
+
+const client = new Client(getPgEnvOptions());
+await client.connect();
+
+const report = await audit(client, {
+ excludeSchemas: ['my_private_schema']
+});
+
+console.log(renderPretty(report));
+console.log(`${report.findings.length} findings`);
+```
+
+
diff --git a/packages/safegres/__tests__/audit.test.ts b/packages/safegres/__tests__/audit.test.ts
new file mode 100644
index 000000000..24304f6fa
--- /dev/null
+++ b/packages/safegres/__tests__/audit.test.ts
@@ -0,0 +1,121 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+import { getConnections, PgTestClient } from 'pgsql-test';
+
+import { audit } from '../src/commands/audit';
+import type { Finding } from '../src/types';
+
+jest.setTimeout(120000);
+
+let pg: PgTestClient;
+let teardown: () => Promise;
+
+async function applyFixture(name: string): Promise {
+ const filepath = path.join(__dirname, 'fixtures', name);
+ const sql = fs.readFileSync(filepath, 'utf8');
+ await pg.any(sql);
+}
+
+function findingsFor(findings: Finding[], schema: string): Finding[] {
+ return findings.filter((f) => f.schema === schema);
+}
+
+function codes(findings: Finding[]): string[] {
+ return findings.map((f) => f.code).sort();
+}
+
+beforeAll(async () => {
+ ({ pg, teardown } = await getConnections());
+});
+
+afterAll(async () => {
+ if (teardown) await teardown();
+});
+
+describe('audit — Script A', () => {
+ it('A1: flags RLS enabled with zero policies', async () => {
+ await applyFixture('a1-rls-enabled-no-policies.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_a1'] });
+ const found = findingsFor(report.findings, 'fx_a1');
+ expect(codes(found)).toEqual(expect.arrayContaining(['A1']));
+ expect(found.find((f) => f.code === 'A1')?.severity).toBe('critical');
+ });
+
+ it('A2: flags grants on table with RLS disabled', async () => {
+ await applyFixture('a2-grants-no-rls.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_a2'] });
+ const found = findingsFor(report.findings, 'fx_a2');
+ const a2 = found.find((f) => f.code === 'A2');
+ expect(a2).toBeDefined();
+ expect(a2?.severity).toBe('high');
+ expect((a2?.context as { roles?: string[] } | undefined)?.roles).toContain('fx_a2_reader');
+ });
+
+ it('A3: flags RLS enabled but not forced', async () => {
+ await applyFixture('a3-rls-not-forced.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_a3'] });
+ const found = findingsFor(report.findings, 'fx_a3');
+ expect(codes(found)).toEqual(expect.arrayContaining(['A3']));
+ });
+
+ it('A4: flags INSERT grant with no matching policy', async () => {
+ await applyFixture('a4-insert-grant-no-policy.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_a4'] });
+ const found = findingsFor(report.findings, 'fx_a4');
+ const a4 = found.find((f) => f.code === 'A4');
+ expect(a4).toBeDefined();
+ expect(a4?.role).toBe('fx_a4_writer');
+ expect(a4?.privilege).toBe('INSERT');
+ // SELECT is covered so A5 shouldn't fire for SELECT
+ expect(found.find((f) => f.code === 'A5' && f.privilege === 'SELECT')).toBeUndefined();
+ });
+
+ it('A6: flags UPDATE coverage missing WITH CHECK for role', async () => {
+ await applyFixture('a6-update-no-with-check.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_a6'] });
+ const found = findingsFor(report.findings, 'fx_a6');
+ const a6 = found.find((f) => f.code === 'A6');
+ expect(a6).toBeDefined();
+ expect(a6?.severity).toBe('info');
+ expect(a6?.role).toBe('fx_a6_editor');
+ expect(a6?.privilege).toBe('UPDATE');
+ });
+
+ it('A7: flags permissive policy with body = literal true (fail-open)', async () => {
+ await applyFixture('a7-trivially-permissive.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_a7'] });
+ const found = findingsFor(report.findings, 'fx_a7');
+ const a7 = found.find((f) => f.code === 'A7');
+ expect(a7).toBeDefined();
+ expect(a7?.severity).toBe('high');
+ expect(a7?.policy).toBe('fx_a7_open');
+ expect((a7?.context as { clauses?: string[] } | undefined)?.clauses).toEqual(['USING']);
+ });
+
+ it('P1: flags policy using VOLATILE function', async () => {
+ await applyFixture('p1-volatile-func.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_p1'] });
+ const found = findingsFor(report.findings, 'fx_p1');
+ const p1 = found.find((f) => f.code === 'P1');
+ expect(p1).toBeDefined();
+ expect(p1?.severity).toBe('high');
+ expect((p1?.context as { function?: string } | undefined)?.function).toBe('fx_p1.slow_auth_lookup');
+ });
+
+ it('P5: flags current_user reference in policy', async () => {
+ await applyFixture('p5-session-user.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_p5'] });
+ const found = findingsFor(report.findings, 'fx_p5');
+ const p5 = found.find((f) => f.code === 'P5');
+ expect(p5).toBeDefined();
+ expect(p5?.severity).toBe('high');
+ });
+
+ it('clean table produces no findings', async () => {
+ await applyFixture('clean-table.sql');
+ const report = await audit(pg.client as never, { schemas: ['fx_clean'] });
+ const found = findingsFor(report.findings, 'fx_clean');
+ expect(found).toEqual([]);
+ });
+});
diff --git a/packages/safegres/__tests__/fixtures/a1-rls-enabled-no-policies.sql b/packages/safegres/__tests__/fixtures/a1-rls-enabled-no-policies.sql
new file mode 100644
index 000000000..9ca3e35e0
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/a1-rls-enabled-no-policies.sql
@@ -0,0 +1,11 @@
+-- A1 seed: RLS enabled on a table with zero policies.
+-- Expected finding: A1 (critical)
+
+CREATE SCHEMA IF NOT EXISTS fx_a1;
+
+CREATE TABLE fx_a1.posts (
+ id bigserial PRIMARY KEY,
+ body text
+);
+
+ALTER TABLE fx_a1.posts ENABLE ROW LEVEL SECURITY;
diff --git a/packages/safegres/__tests__/fixtures/a2-grants-no-rls.sql b/packages/safegres/__tests__/fixtures/a2-grants-no-rls.sql
new file mode 100644
index 000000000..3829526c7
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/a2-grants-no-rls.sql
@@ -0,0 +1,18 @@
+-- A2 seed: grants to a non-owner role on a table with RLS disabled.
+-- Expected finding: A2 (high)
+
+CREATE SCHEMA IF NOT EXISTS fx_a2;
+
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a2_reader') THEN
+ CREATE ROLE fx_a2_reader NOLOGIN;
+ END IF;
+END $$;
+
+CREATE TABLE fx_a2.widgets (
+ id bigserial PRIMARY KEY,
+ body text
+);
+
+GRANT SELECT, INSERT ON fx_a2.widgets TO fx_a2_reader;
+-- RLS intentionally NOT enabled.
diff --git a/packages/safegres/__tests__/fixtures/a3-rls-not-forced.sql b/packages/safegres/__tests__/fixtures/a3-rls-not-forced.sql
new file mode 100644
index 000000000..c78920205
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/a3-rls-not-forced.sql
@@ -0,0 +1,15 @@
+-- A3 seed: RLS enabled but not FORCE'd, with at least one policy so A1 doesn't trigger.
+-- Expected finding: A3 (medium)
+
+CREATE SCHEMA IF NOT EXISTS fx_a3;
+
+CREATE TABLE fx_a3.notes (
+ id bigserial PRIMARY KEY,
+ body text,
+ owner_id uuid
+);
+
+ALTER TABLE fx_a3.notes ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY notes_select ON fx_a3.notes
+ FOR SELECT USING (true);
diff --git a/packages/safegres/__tests__/fixtures/a4-insert-grant-no-policy.sql b/packages/safegres/__tests__/fixtures/a4-insert-grant-no-policy.sql
new file mode 100644
index 000000000..63b73bf79
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/a4-insert-grant-no-policy.sql
@@ -0,0 +1,25 @@
+-- A4 seed: INSERT grant to a role, but only a SELECT policy exists for that role.
+-- Expected findings: A4 (high), and the INSERT will fail at runtime.
+
+CREATE SCHEMA IF NOT EXISTS fx_a4;
+
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a4_writer') THEN
+ CREATE ROLE fx_a4_writer NOLOGIN;
+ END IF;
+END $$;
+
+CREATE TABLE fx_a4.events (
+ id bigserial PRIMARY KEY,
+ body text,
+ owner_id uuid
+);
+
+ALTER TABLE fx_a4.events ENABLE ROW LEVEL SECURITY;
+
+GRANT SELECT, INSERT ON fx_a4.events TO fx_a4_writer;
+
+CREATE POLICY events_select ON fx_a4.events
+ FOR SELECT TO fx_a4_writer
+ USING (true);
+-- No INSERT policy for fx_a4_writer.
diff --git a/packages/safegres/__tests__/fixtures/a6-update-no-with-check.sql b/packages/safegres/__tests__/fixtures/a6-update-no-with-check.sql
new file mode 100644
index 000000000..fec104fe3
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/a6-update-no-with-check.sql
@@ -0,0 +1,28 @@
+-- A6 seed: UPDATE policy with USING but no WITH CHECK.
+-- Expected finding: A6 (high)
+
+CREATE SCHEMA IF NOT EXISTS fx_a6;
+
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a6_editor') THEN
+ CREATE ROLE fx_a6_editor NOLOGIN;
+ END IF;
+END $$;
+
+CREATE TABLE fx_a6.docs (
+ id bigserial PRIMARY KEY,
+ owner_id uuid NOT NULL,
+ body text
+);
+
+ALTER TABLE fx_a6.docs ENABLE ROW LEVEL SECURITY;
+
+GRANT SELECT, UPDATE ON fx_a6.docs TO fx_a6_editor;
+
+CREATE POLICY docs_update ON fx_a6.docs
+ FOR UPDATE TO fx_a6_editor
+ USING (true);
+-- missing WITH CHECK — rows can be moved out of the visible set.
+
+CREATE POLICY docs_select ON fx_a6.docs
+ FOR SELECT TO fx_a6_editor USING (true);
diff --git a/packages/safegres/__tests__/fixtures/a7-trivially-permissive.sql b/packages/safegres/__tests__/fixtures/a7-trivially-permissive.sql
new file mode 100644
index 000000000..319b47a71
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/a7-trivially-permissive.sql
@@ -0,0 +1,24 @@
+-- A7 seed: permissive policy whose body is the literal `true` —
+-- effectively "RLS enabled, nothing gated" ⇒ fail-open.
+-- Expected finding: A7 (high)
+
+CREATE SCHEMA IF NOT EXISTS fx_a7;
+
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a7_reader') THEN
+ CREATE ROLE fx_a7_reader;
+ END IF;
+END $$;
+
+CREATE TABLE fx_a7.posts (
+ id bigserial PRIMARY KEY,
+ body text
+);
+
+ALTER TABLE fx_a7.posts ENABLE ROW LEVEL SECURITY;
+ALTER TABLE fx_a7.posts FORCE ROW LEVEL SECURITY;
+
+CREATE POLICY fx_a7_open ON fx_a7.posts FOR SELECT TO fx_a7_reader USING (true);
+
+GRANT USAGE ON SCHEMA fx_a7 TO fx_a7_reader;
+GRANT SELECT ON fx_a7.posts TO fx_a7_reader;
diff --git a/packages/safegres/__tests__/fixtures/clean-table.sql b/packages/safegres/__tests__/fixtures/clean-table.sql
new file mode 100644
index 000000000..b419669e5
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/clean-table.sql
@@ -0,0 +1,35 @@
+-- Baseline: correctly-configured table with RLS + FORCE + grants covered by
+-- non-trivial policies.
+-- Expected findings: none.
+
+CREATE SCHEMA IF NOT EXISTS fx_clean;
+
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_clean_user') THEN
+ CREATE ROLE fx_clean_user NOLOGIN;
+ END IF;
+END $$;
+
+CREATE TABLE fx_clean.items (
+ id bigserial PRIMARY KEY,
+ owner_id uuid NOT NULL,
+ body text
+);
+
+ALTER TABLE fx_clean.items ENABLE ROW LEVEL SECURITY;
+ALTER TABLE fx_clean.items FORCE ROW LEVEL SECURITY;
+
+GRANT SELECT, INSERT, UPDATE ON fx_clean.items TO fx_clean_user;
+
+-- Non-trivial policies gated on the row owner column.
+CREATE POLICY items_select ON fx_clean.items
+ FOR SELECT TO fx_clean_user
+ USING (owner_id IS NOT NULL);
+
+CREATE POLICY items_insert ON fx_clean.items
+ FOR INSERT TO fx_clean_user
+ WITH CHECK (owner_id IS NOT NULL);
+
+CREATE POLICY items_update ON fx_clean.items
+ FOR UPDATE TO fx_clean_user
+ USING (owner_id IS NOT NULL) WITH CHECK (owner_id IS NOT NULL);
diff --git a/packages/safegres/__tests__/fixtures/p1-volatile-func.sql b/packages/safegres/__tests__/fixtures/p1-volatile-func.sql
new file mode 100644
index 000000000..835e98864
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/p1-volatile-func.sql
@@ -0,0 +1,27 @@
+-- P1 seed: policy references a user-defined VOLATILE function.
+-- Expected finding: P1 (high) — per-row evaluation.
+
+CREATE SCHEMA IF NOT EXISTS fx_p1;
+
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_p1_user') THEN
+ CREATE ROLE fx_p1_user NOLOGIN;
+ END IF;
+END $$;
+
+CREATE FUNCTION fx_p1.slow_auth_lookup() RETURNS uuid
+LANGUAGE sql
+VOLATILE
+AS $$ SELECT gen_random_uuid() $$;
+
+CREATE TABLE fx_p1.records (
+ id bigserial PRIMARY KEY,
+ owner_id uuid
+);
+
+ALTER TABLE fx_p1.records ENABLE ROW LEVEL SECURITY;
+GRANT SELECT ON fx_p1.records TO fx_p1_user;
+
+CREATE POLICY records_select ON fx_p1.records
+ FOR SELECT TO fx_p1_user
+ USING (owner_id = fx_p1.slow_auth_lookup());
diff --git a/packages/safegres/__tests__/fixtures/p5-session-user.sql b/packages/safegres/__tests__/fixtures/p5-session-user.sql
new file mode 100644
index 000000000..f134655f1
--- /dev/null
+++ b/packages/safegres/__tests__/fixtures/p5-session-user.sql
@@ -0,0 +1,22 @@
+-- P5 seed: policy references current_user for tenant gating.
+-- Expected finding: P5 (high).
+
+CREATE SCHEMA IF NOT EXISTS fx_p5;
+
+DO $$ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_p5_user') THEN
+ CREATE ROLE fx_p5_user NOLOGIN;
+ END IF;
+END $$;
+
+CREATE TABLE fx_p5.items (
+ id bigserial PRIMARY KEY,
+ owner_name name
+);
+
+ALTER TABLE fx_p5.items ENABLE ROW LEVEL SECURITY;
+GRANT SELECT ON fx_p5.items TO fx_p5_user;
+
+CREATE POLICY items_select ON fx_p5.items
+ FOR SELECT TO fx_p5_user
+ USING (owner_name = current_user);
diff --git a/packages/safegres/__tests__/parse.test.ts b/packages/safegres/__tests__/parse.test.ts
new file mode 100644
index 000000000..9304d096f
--- /dev/null
+++ b/packages/safegres/__tests__/parse.test.ts
@@ -0,0 +1,30 @@
+import { parsePolicyExpression } from '../src/ast/parse';
+import { columnRefPath, findAll, funcNameQualified } from '../src/ast/walk';
+
+describe('parsePolicyExpression', () => {
+ it('returns null on empty / null', async () => {
+ expect(await parsePolicyExpression(null)).toBeNull();
+ expect(await parsePolicyExpression('')).toBeNull();
+ expect(await parsePolicyExpression(' ')).toBeNull();
+ });
+
+ it('parses a simple column = fn() predicate', async () => {
+ const p = await parsePolicyExpression('(owner_id = my_auth.current_user_id())');
+ expect(p).not.toBeNull();
+ const funcs = findAll(p!.ast, 'FuncCall');
+ expect(funcs.length).toBe(1);
+ expect(funcNameQualified(funcs[0])).toBe('my_auth.current_user_id');
+ });
+
+ it('parses pg_get_expr-style output with double parens', async () => {
+ const p = await parsePolicyExpression('((actor_id IS NOT NULL))');
+ expect(p).not.toBeNull();
+ });
+
+ it('walks ColumnRef fields', async () => {
+ const p = await parsePolicyExpression('(s.actor_id = 1)');
+ const refs = findAll(p!.ast, 'ColumnRef');
+ expect(refs.length).toBeGreaterThan(0);
+ expect(columnRefPath(refs[0])).toEqual(['s', 'actor_id']);
+ });
+});
diff --git a/packages/safegres/__tests__/roles.test.ts b/packages/safegres/__tests__/roles.test.ts
new file mode 100644
index 000000000..24669186a
--- /dev/null
+++ b/packages/safegres/__tests__/roles.test.ts
@@ -0,0 +1,27 @@
+import { resolveRoles } from '../src/pg/roles';
+
+describe('resolveRoles', () => {
+ const allRoles = [
+ { name: 'authenticated', canLogin: true, isSuper: false, isSystem: false },
+ { name: 'anonymous', canLogin: true, isSuper: false, isSystem: false },
+ { name: 'postgres', canLogin: true, isSuper: true, isSystem: false },
+ { name: 'pg_read_all_stats', canLogin: false, isSuper: false, isSystem: true },
+ { name: 'pg_monitor', canLogin: false, isSuper: false, isSystem: true }
+ ];
+
+ it('defaults exclude system roles', () => {
+ const { roles, excluded } = resolveRoles(allRoles, undefined);
+ expect(roles).toEqual(['authenticated', 'anonymous', 'postgres']);
+ expect(excluded).toEqual(expect.arrayContaining(['pg_read_all_stats', 'pg_monitor']));
+ });
+
+ it('honors --roles filter', () => {
+ const { roles } = resolveRoles(allRoles, ['authenticated']);
+ expect(roles).toEqual(['authenticated']);
+ });
+
+ it('applies --exclude-roles on top', () => {
+ const { roles } = resolveRoles(allRoles, undefined, ['postgres']);
+ expect(roles).toEqual(['authenticated', 'anonymous']);
+ });
+});
diff --git a/packages/safegres/jest.config.js b/packages/safegres/jest.config.js
new file mode 100644
index 000000000..475aa4db7
--- /dev/null
+++ b/packages/safegres/jest.config.js
@@ -0,0 +1,19 @@
+/** @type {import('ts-jest').JestConfigWithTsJest} */
+module.exports = {
+ preset: 'ts-jest',
+ testEnvironment: 'node',
+ transform: {
+ '^.+\\.tsx?$': [
+ 'ts-jest',
+ {
+ babelConfig: false,
+ tsconfig: 'tsconfig.json'
+ }
+ ]
+ },
+ transformIgnorePatterns: [`/node_modules/*`],
+ testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
+ testPathIgnorePatterns: ['/node_modules/', '/__tests__/fixtures/'],
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
+ modulePathIgnorePatterns: ['dist/*']
+};
diff --git a/packages/safegres/package.json b/packages/safegres/package.json
new file mode 100644
index 000000000..e1bdae785
--- /dev/null
+++ b/packages/safegres/package.json
@@ -0,0 +1,61 @@
+{
+ "name": "safegres",
+ "version": "0.1.0",
+ "author": "Constructive ",
+ "description": "Pure-PostgreSQL RLS auditor: grants, RLS flags, policy coverage, and AST-level anti-pattern detection. Re-exports the Authz*/Data*/Relation*/View* type registry for downstream auditors.",
+ "main": "index.js",
+ "module": "esm/index.js",
+ "types": "index.d.ts",
+ "homepage": "https://github.com/constructive-io/constructive",
+ "license": "MIT",
+ "publishConfig": {
+ "access": "public",
+ "directory": "dist"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/constructive-io/constructive"
+ },
+ "bugs": {
+ "url": "https://github.com/constructive-io/constructive/issues"
+ },
+ "bin": {
+ "safegres": "dist/cli.js"
+ },
+ "scripts": {
+ "clean": "makage clean",
+ "prepack": "npm run build",
+ "build": "makage build",
+ "build:dev": "makage build --dev",
+ "lint": "eslint . --fix",
+ "test": "jest --passWithNoTests",
+ "test:watch": "jest --watch"
+ },
+ "keywords": [
+ "postgres",
+ "rls",
+ "row-level-security",
+ "audit",
+ "security",
+ "safegres",
+ "constructive"
+ ],
+ "dependencies": {
+ "@inquirerer/utils": "^3.3.5",
+ "@pgpmjs/logger": "workspace:^",
+ "@pgsql/traverse": "^17.2.5",
+ "@pgsql/types": "^17.6.2",
+ "inquirerer": "^4.7.0",
+ "pg": "^8.16.0",
+ "pg-env": "^1.9.0",
+ "pgsql-deparser": "^17.18.2",
+ "pgsql-parser": "^17.9.14",
+ "yanse": "^0.2.1"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "@types/pg": "^8.11.10",
+ "makage": "^0.3.0",
+ "pgsql-test": "workspace:^"
+ }
+}
diff --git a/packages/safegres/src/ast/helpers.ts b/packages/safegres/src/ast/helpers.ts
new file mode 100644
index 000000000..08025dbd5
--- /dev/null
+++ b/packages/safegres/src/ast/helpers.ts
@@ -0,0 +1,37 @@
+/**
+ * Small typed helpers for inspecting `pgsql-parser` AST nodes.
+ *
+ * Operates on the raw shape returned by `parse()` rather than the typed
+ * `@pgsql/types` view, so the runtime cost is just object lookups.
+ */
+
+import type { PgAstNode } from './parse';
+
+type Obj = Record;
+
+function isObj(x: unknown): x is Obj {
+ return typeof x === 'object' && x !== null;
+}
+
+/** Extract the single wrapping key (e.g. `A_Expr`, `BoolExpr`) and its payload. */
+export function unwrap(node: PgAstNode): { kind: string; body: Obj } | null {
+ if (!isObj(node)) return null;
+ const keys = Object.keys(node);
+ if (keys.length !== 1) return null;
+ const body = (node as Obj)[keys[0]];
+ if (!isObj(body)) return null;
+ return { kind: keys[0], body };
+}
+
+/** Returns the boolean literal value, or null if not an `A_Const(bool)`. */
+export function boolConst(node: PgAstNode): boolean | null {
+ const u = unwrap(node);
+ if (!u || u.kind !== 'A_Const') return null;
+ // `pgsql-parser` represents `true` as `{ boolval: { boolval: true } }` and
+ // `false` as `{ boolval: {} }` (the parser elides the default value). The
+ // *presence* of the `boolval` key is what identifies a boolean literal.
+ if (!('boolval' in u.body)) return null;
+ const boolval = u.body.boolval as Obj | undefined;
+ if (!boolval || typeof boolval !== 'object') return null;
+ return boolval.boolval === true;
+}
diff --git a/packages/safegres/src/ast/parse.ts b/packages/safegres/src/ast/parse.ts
new file mode 100644
index 000000000..b8b36760e
--- /dev/null
+++ b/packages/safegres/src/ast/parse.ts
@@ -0,0 +1,88 @@
+/**
+ * Wrapper around `pgsql-parser` that handles the reality that RLS predicates
+ * appearing in `pg_get_expr(polqual, polrelid)` are fragments, not statements.
+ *
+ * We wrap the fragment in a `SELECT … WHERE ` so the parser accepts it,
+ * then pull the `whereClause` out of the resulting AST.
+ */
+
+import { parse } from 'pgsql-parser';
+
+/** Opaque AST node — intentionally `unknown` to keep us loose against pgsql-parser upgrades. */
+export type PgAstNode = unknown;
+
+export interface PolicyExpression {
+ /** Original text (exactly as returned by pg_get_expr). */
+ text: string;
+ /** Root AST node of the expression (not the wrapping SELECT). */
+ ast: PgAstNode;
+}
+
+export class PolicyParseError extends Error {
+ constructor(public readonly expr: string, public readonly cause: unknown) {
+ super(`Failed to parse policy expression: ${expr}`);
+ }
+}
+
+/**
+ * Parse a policy expression fragment.
+ *
+ * `parse` from pgsql-parser v17 is async (it awaits a WASM load), so this
+ * wrapper is async too.
+ *
+ * @param expr The text from `pg_get_expr(...)`.
+ * @returns Parsed AST, or `null` if `expr` is null/empty.
+ * @throws `PolicyParseError` on parse failure.
+ */
+export async function parsePolicyExpression(expr: string | null): Promise {
+ if (expr === null || expr === undefined) return null;
+ const trimmed = expr.trim();
+ if (trimmed === '') return null;
+
+ // Strip outer parens that Postgres adds to pg_get_expr output, so the
+ // wrapping `WHERE` is cleaner. We only strip *one* pair and only when
+ // balanced — otherwise we leave the text alone and let the parser deal.
+ const inner = stripOuterParens(trimmed);
+
+ const wrapped = `SELECT 1 FROM __safegres_audit_dummy WHERE (${inner})`;
+ let parsed: unknown;
+ try {
+ parsed = await parse(wrapped);
+ } catch (err) {
+ throw new PolicyParseError(expr, err);
+ }
+
+ const ast = extractWhereClause(parsed);
+ if (!ast) {
+ throw new PolicyParseError(expr, new Error('no whereClause in parsed SELECT'));
+ }
+
+ return { text: expr, ast };
+}
+
+function stripOuterParens(s: string): string {
+ if (s.length < 2 || s[0] !== '(' || s[s.length - 1] !== ')') return s;
+ let depth = 0;
+ for (let i = 0; i < s.length; i++) {
+ if (s[i] === '(') depth++;
+ else if (s[i] === ')') {
+ depth--;
+ if (depth === 0 && i !== s.length - 1) return s;
+ }
+ }
+ return s.slice(1, -1);
+}
+
+function extractWhereClause(parsed: unknown): PgAstNode | null {
+ // pgsql-parser v17 returns `{ stmts: [{ stmt: { SelectStmt: { whereClause: … } } }] }`
+ if (!parsed || typeof parsed !== 'object') return null;
+ const asRecord = parsed as Record;
+ const stmts = asRecord.stmts;
+ if (!Array.isArray(stmts) || stmts.length === 0) return null;
+ const first = stmts[0] as Record;
+ const stmt = (first.stmt ?? first) as Record;
+ const selectStmt = stmt.SelectStmt as Record | undefined;
+ if (!selectStmt) return null;
+ const where = selectStmt.whereClause;
+ return where ?? null;
+}
diff --git a/packages/safegres/src/ast/walk.ts b/packages/safegres/src/ast/walk.ts
new file mode 100644
index 000000000..43258a490
--- /dev/null
+++ b/packages/safegres/src/ast/walk.ts
@@ -0,0 +1,88 @@
+/**
+ * Domain-specific AST helpers for the safegres auditor, layered on top of
+ * `@pgsql/traverse`'s `walk` / `NodePath`.
+ */
+
+import { NodePath, walk } from '@pgsql/traverse';
+
+import type { PgAstNode } from './parse';
+
+/** Find every node tagged with `tag` (e.g. `FuncCall`, `ColumnRef`). */
+export function findAll(root: PgAstNode, tag: string): Record[] {
+ const out: Record[] = [];
+ walk(root as object, (path: NodePath) => {
+ if (path.tag === tag) {
+ out.push(path.node as Record);
+ }
+ });
+ return out;
+}
+
+/** Visit every tagged node. `visit(node, tag, parent)` matches the old API. */
+export type Visitor = (
+ node: Record,
+ tag: string,
+ parent: Record | null
+) => void;
+
+export function visitAll(root: PgAstNode, visit: Visitor): void {
+ walk(root as object, (path: NodePath) => {
+ visit(
+ path.node as Record,
+ path.tag,
+ (path.parent?.node as Record | undefined) ?? null
+ );
+ });
+}
+
+/**
+ * Extract the printable name of a `FuncCall.funcname` field.
+ *
+ * `funcname` is an array of `{ String: { sval: "..." } }` nodes — usually one
+ * element (bare name) or two (schema-qualified).
+ */
+export function funcNameParts(funcCall: Record): { schema?: string; name: string } {
+ const funcname = funcCall.funcname;
+ if (!Array.isArray(funcname)) return { name: '' };
+
+ const parts = funcname
+ .map((n) => {
+ const rec = n as Record;
+ const str = (rec.String ?? rec.string) as Record | undefined;
+ if (!str) return '';
+ return String((str.sval ?? str.str) ?? '');
+ })
+ .filter((s) => s.length > 0);
+
+ if (parts.length >= 2) return { schema: parts[0], name: parts[parts.length - 1] };
+ if (parts.length === 1) return { name: parts[0] };
+ return { name: '' };
+}
+
+/** Get the printable qualified name (`schema.name` or `name`). */
+export function funcNameQualified(funcCall: Record): string {
+ const { schema, name } = funcNameParts(funcCall);
+ return schema ? `${schema}.${name}` : name;
+}
+
+/**
+ * Get a ColumnRef's dotted name path. Example: `s.actor_id` → `['s', 'actor_id']`.
+ * Returns `['*']` for `SELECT *`.
+ */
+export function columnRefPath(columnRef: Record): string[] {
+ const fields = columnRef.fields;
+ if (!Array.isArray(fields)) return [];
+ const out: string[] = [];
+ for (const f of fields) {
+ const rec = f as Record;
+ const str = (rec.String ?? rec.string) as Record | undefined;
+ if (str) {
+ out.push(String((str.sval ?? str.str) ?? ''));
+ continue;
+ }
+ if ('A_Star' in rec) {
+ out.push('*');
+ }
+ }
+ return out;
+}
diff --git a/packages/safegres/src/checks/anti-patterns.ts b/packages/safegres/src/checks/anti-patterns.ts
new file mode 100644
index 000000000..0004deeba
--- /dev/null
+++ b/packages/safegres/src/checks/anti-patterns.ts
@@ -0,0 +1,249 @@
+import type { PgAstNode } from '../ast/parse';
+import { parsePolicyExpression } from '../ast/parse';
+import { columnRefPath, findAll, funcNameParts, funcNameQualified, visitAll } from '../ast/walk';
+import { boolConst } from '../ast/helpers';
+import type { PolicyInfo, TableSnapshot } from '../pg/introspect';
+import type { ProcVolatility } from '../pg/proc';
+import type { Finding } from '../types';
+
+/**
+ * Function names we consider "safe" (stable) for policy predicates, even when
+ * pg_proc marks them volatile. These are the well-known Postgres session
+ * primitives that the planner treats as constant for the duration of a query.
+ *
+ * This is intentionally narrow — everything else that's volatile gets flagged.
+ */
+const SAFE_VOLATILE_ALLOWLIST = new Set([
+ 'current_setting', // only because PostgreSQL optimises this specially
+ 'txid_current',
+ 'clock_timestamp', // still flagged elsewhere — listed here to document intent
+ 'now',
+ 'statement_timestamp',
+ 'transaction_timestamp'
+]);
+
+/**
+ * Anti-pattern P1: policy predicate invokes a VOLATILE function (per-row
+ * evaluation, breaks the planner's ability to hoist or cache).
+ *
+ * Also flags SECURITY DEFINER wrappers here (P1b): they can't be inlined and
+ * become per-row function calls.
+ *
+ * Expected input: a volatility map keyed by `schema.name` (see pg/proc.ts).
+ */
+export function checkVolatileFunctions(
+ table: TableSnapshot,
+ expr: PgAstNode,
+ volatility: Map,
+ policyName: string
+): Finding[] {
+ const out: Finding[] = [];
+
+ for (const fc of findAll(expr, 'FuncCall')) {
+ const { schema, name } = funcNameParts(fc);
+ const qualified = funcNameQualified(fc);
+ const lookupKey = schema ? `${schema}.${name}` : name;
+ const info = volatility.get(qualified) ?? volatility.get(lookupKey) ?? volatility.get(name);
+
+ if (!info) continue; // unknown function (e.g. operator expansion) — skip silently
+ if (info.volatility !== 'v') continue;
+ if (SAFE_VOLATILE_ALLOWLIST.has(info.name.split('.').pop() ?? '')) continue;
+ if (info.isSystem && SAFE_VOLATILE_ALLOWLIST.has(name)) continue;
+
+ out.push({
+ code: 'P1',
+ severity: 'high',
+ category: 'anti-pattern',
+ schema: table.schema,
+ table: table.name,
+ policy: policyName,
+ message:
+ `Policy "${policyName}" on ${table.schema}.${table.name} calls VOLATILE function ${info.name}`,
+ hint:
+ 'Volatile functions re-execute for every row considered by the planner. Mark the function STABLE if safe, or precompute the result in a CTE / subquery.',
+ context: { function: info.name, securityDefiner: info.isSecurityDefiner }
+ });
+
+ if (info.isSecurityDefiner) {
+ out.push({
+ code: 'P1b',
+ severity: 'medium',
+ category: 'anti-pattern',
+ schema: table.schema,
+ table: table.name,
+ policy: policyName,
+ message:
+ `Policy "${policyName}" on ${table.schema}.${table.name} calls SECURITY DEFINER function ${info.name}`,
+ hint: 'SECURITY DEFINER wrappers can\'t be inlined by the planner — every row forces a function call. Convert to a STABLE SQL function or replace with a direct expression.',
+ context: { function: info.name }
+ });
+ }
+ }
+
+ return out;
+}
+
+/**
+ * Anti-pattern P5: policy references `session_user`, `current_user`, or
+ * `pg_has_role(...)` for tenant gating. These bypass the role-and-JWT layer
+ * that the rest of the auth model is built on, and they silently evaluate
+ * against the *login* role instead of the `SET LOCAL role` role, which
+ * produces confusing RLS behavior across pooled connections.
+ */
+export function checkSessionUserGating(
+ table: TableSnapshot,
+ expr: PgAstNode,
+ policyName: string
+): Finding[] {
+ const out: Finding[] = [];
+
+ // session_user / current_user appear as CurrentOfExpr-adjacent nodes in some
+ // parser versions, but the common form is `SQLValueFunction` with `op:
+ // SVFOP_CURRENT_USER` (4) or SVFOP_SESSION_USER (6).
+ visitAll(expr, (node, tag) => {
+ if (tag === 'SQLValueFunction') {
+ const op = node.op;
+ const opStr = typeof op === 'string' ? op : String(op);
+ if (/CURRENT_USER|SESSION_USER/i.test(opStr)) {
+ out.push({
+ code: 'P5',
+ severity: 'high',
+ category: 'anti-pattern',
+ schema: table.schema,
+ table: table.name,
+ policy: policyName,
+ message:
+ `Policy "${policyName}" on ${table.schema}.${table.name} references ${opStr.replace('SVFOP_', '').toLowerCase()}`,
+ hint: 'Use a JWT-backed helper (`current_setting(\'jwt.claims...\')`) or a dedicated STABLE auth function. `current_user` / `session_user` bypass the app auth layer.'
+ });
+ }
+ }
+ });
+
+ // pg_has_role(text, text, text)
+ for (const fc of findAll(expr, 'FuncCall')) {
+ const { name } = funcNameParts(fc);
+ if (name === 'pg_has_role') {
+ out.push({
+ code: 'P5',
+ severity: 'high',
+ category: 'anti-pattern',
+ schema: table.schema,
+ table: table.name,
+ policy: policyName,
+ message:
+ `Policy "${policyName}" on ${table.schema}.${table.name} uses pg_has_role() for tenant gating`,
+ hint: 'pg_has_role() checks Postgres role grants — not application-level authorization. Consider a dedicated auth helper instead.'
+ });
+ }
+ }
+
+ // Also flag raw ColumnRef `current_user` — parser versions vary.
+ for (const cr of findAll(expr, 'ColumnRef')) {
+ const path = columnRefPath(cr);
+ if (path.length === 1 && (path[0] === 'current_user' || path[0] === 'session_user')) {
+ out.push({
+ code: 'P5',
+ severity: 'high',
+ category: 'anti-pattern',
+ schema: table.schema,
+ table: table.name,
+ policy: policyName,
+ message:
+ `Policy "${policyName}" on ${table.schema}.${table.name} references ${path[0]}`,
+ hint: 'Use a JWT-backed helper or STABLE auth function instead of raw session/current role.'
+ });
+ }
+ }
+
+ return out;
+}
+
+/**
+ * Anti-pattern A7: trivially-permissive policy body.
+ *
+ * A permissive policy whose body is the literal `true` (and has no tightening
+ * `WITH CHECK` clause) adds zero security — it's equivalent to not having RLS
+ * at all for the covered command. This is different from an intentional
+ * *restrictive* `true` (which would require all rows to satisfy it). We only
+ * flag `permissive = true` here because Postgres defaults to PERMISSIVE and
+ * `USING (true)` is the most common accidental "fail-open" shape.
+ *
+ * Severity: HIGH — any auditor should see this as RLS-not-actually-enforced.
+ *
+ * Input: the parsed USING and WITH CHECK ASTs (may be null if empty).
+ */
+export function checkTriviallyPermissive(
+ table: TableSnapshot,
+ policy: PolicyInfo,
+ usingAst: PgAstNode | null,
+ withCheckAst: PgAstNode | null
+): Finding | null {
+ if (!policy.permissive) return null;
+
+ const usingIsTrue = usingAst ? boolConst(usingAst) === true : false;
+ const withCheckIsTrue = withCheckAst ? boolConst(withCheckAst) === true : false;
+
+ // Figure out which clauses are "open".
+ // - If USING exists, it must be literal true to be "open" on the read side.
+ // - If WITH CHECK exists, it must also be literal true.
+ // - If a clause is absent, the command doesn't care about it (e.g. INSERT
+ // has no USING; SELECT has no WITH CHECK) — that's a separate concern
+ // covered by the coverage checks.
+ // We flag when *every* present clause is literal `true`.
+ const presentClauses: Array<'USING' | 'WITH CHECK'> = [];
+ const trivialClauses: Array<'USING' | 'WITH CHECK'> = [];
+ if (policy.using !== null) {
+ presentClauses.push('USING');
+ if (usingIsTrue) trivialClauses.push('USING');
+ }
+ if (policy.withCheck !== null) {
+ presentClauses.push('WITH CHECK');
+ if (withCheckIsTrue) trivialClauses.push('WITH CHECK');
+ }
+
+ if (presentClauses.length === 0) return null;
+ if (trivialClauses.length !== presentClauses.length) return null;
+
+ const clauseList = trivialClauses.join(' and ');
+ return {
+ code: 'A7',
+ severity: 'high',
+ category: 'anti-pattern',
+ schema: table.schema,
+ table: table.name,
+ policy: policy.name,
+ message: `Policy "${policy.name}" on ${table.schema}.${table.name} is trivially permissive (${clauseList} = true)`,
+ hint: 'A permissive policy whose body is the literal `true` imposes no constraint. Either tighten the predicate (reference the authenticated user / membership) or drop the policy and use a GRANT REVOKE model.',
+ context: { cmd: policy.cmd, clauses: trivialClauses }
+ };
+}
+
+/**
+ * Walk a policy expression, collecting unique function `(schema, name)` tuples
+ * so we can resolve volatility in one batch query.
+ */
+export function collectFunctionNames(expr: PgAstNode): Array<{ schema?: string; name: string }> {
+ const out: Array<{ schema?: string; name: string }> = [];
+ const seen = new Set();
+ for (const fc of findAll(expr, 'FuncCall')) {
+ const parts = funcNameParts(fc);
+ const key = `${parts.schema ?? ''}.${parts.name}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(parts);
+ }
+ return out;
+}
+
+/** Parse a policy expression or return `null` on empty input. Logs parse errors to stderr. */
+export async function parseOrNull(expr: string | null, context: string): Promise {
+ try {
+ const parsed = await parsePolicyExpression(expr);
+ return parsed?.ast ?? null;
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn(`[safegres-audit] could not parse ${context}: ${(err as Error).message}`);
+ return null;
+ }
+}
diff --git a/packages/safegres/src/checks/coverage.ts b/packages/safegres/src/checks/coverage.ts
new file mode 100644
index 000000000..f346d79f5
--- /dev/null
+++ b/packages/safegres/src/checks/coverage.ts
@@ -0,0 +1,148 @@
+import type { PgPrivilege, PolicyCmd, PolicyInfo, TableSnapshot } from '../pg/introspect';
+import type { Finding } from '../types';
+
+/**
+ * Coverage rules for row-level security.
+ *
+ * The security posture of a table is determined per `(role, privilege)` — not
+ * per individual policy. A grant is "covered" iff at least one applicable
+ * *permissive* policy supplies the clause that privilege requires:
+ *
+ * - INSERT → a `WITH CHECK` clause (USING is not valid on FOR INSERT).
+ * - SELECT → a `USING` clause.
+ * - UPDATE → a `USING` clause (the FOR UPDATE visibility gate). If absent,
+ * writes against the row are impossible and the grant is dead.
+ * - DELETE → a `USING` clause.
+ *
+ * Postgres fails writes at runtime when the required clause is missing across
+ * *every* applicable permissive policy, so that's the only bar this check
+ * holds us to. Whether an UPDATE policy also pairs a WITH CHECK (defense in
+ * depth against row smuggling) is a separate, weaker concern that is
+ * covered by `checkUpdateWithCheckCoverage` below as an INFO-level finding.
+ */
+
+/** What clause kind satisfies coverage for a given privilege. */
+type ClauseKind = 'USING' | 'WITH CHECK';
+
+const CLAUSE_REQUIRED: Partial> = {
+ SELECT: 'USING',
+ INSERT: 'WITH CHECK',
+ UPDATE: 'USING',
+ DELETE: 'USING'
+};
+
+/** `polcmd` → the single verb the policy can satisfy (`ALL` satisfies every verb). */
+const POLICY_CMDS: Record = {
+ SELECT: ['SELECT'],
+ INSERT: ['INSERT'],
+ UPDATE: ['UPDATE'],
+ DELETE: ['DELETE'],
+ ALL: ['SELECT', 'INSERT', 'UPDATE', 'DELETE']
+};
+
+/**
+ * A4 / A5: Grant of INSERT/UPDATE/DELETE (A4, high) or SELECT (A5, medium) to
+ * a role but no applicable permissive policy supplies the clause that verb
+ * needs. A4 means "writes silently fail at runtime". A5 means "queries
+ * silently return 0 rows".
+ */
+export function checkCoverageGaps(table: TableSnapshot): Finding[] {
+ if (!table.rlsEnabled) return []; // A2 covers the "RLS off entirely" case.
+ const out: Finding[] = [];
+
+ for (const grant of table.grants) {
+ if (grant.role === table.owner) continue;
+ if (grant.role === 'PUBLIC') continue;
+ if (grant.bypassRls) continue; // Superusers and BYPASSRLS roles aren't subject to policies.
+
+ const requiredClause = CLAUSE_REQUIRED[grant.privilege];
+ if (!requiredClause) continue; // TRUNCATE / REFERENCES / TRIGGER don't participate in RLS.
+
+ const covered = table.policies.some((p) =>
+ policyProvidesClause(p, grant.role, grant.privilege, requiredClause)
+ );
+ if (covered) continue;
+
+ const [code, severity]: [string, Finding['severity']] = grant.privilege === 'SELECT'
+ ? ['A5', 'medium']
+ : ['A4', 'high'];
+
+ out.push({
+ code,
+ severity,
+ category: 'coverage',
+ schema: table.schema,
+ table: table.name,
+ role: grant.role,
+ privilege: grant.privilege,
+ message:
+ `Role ${grant.role} has ${grant.privilege} grant on ${table.schema}.${table.name} but no permissive policy supplies ${requiredClause} for ${grant.privilege}`,
+ hint: grant.privilege === 'SELECT'
+ ? 'Queries by this role will silently return 0 rows. Add a permissive policy FOR SELECT TO that role, or revoke the grant.'
+ : `${grant.privilege} by this role will fail at runtime. Add a permissive policy FOR ${grant.privilege} TO that role supplying ${requiredClause}, or revoke the grant.`
+ });
+ }
+
+ return out;
+}
+
+/**
+ * A6 (informational): UPDATE coverage exists for a role but no applicable
+ * permissive UPDATE policy pairs a `WITH CHECK`. This is defense-in-depth —
+ * without WITH CHECK, updates can move rows out of the visible scope (a
+ * "row smuggling" pattern). Low severity because it does not cause runtime
+ * failures and in many designs is by construction safe (e.g. the columns
+ * that define scope are not writable, or a trigger re-asserts scope).
+ */
+export function checkUpdateWithCheckCoverage(table: TableSnapshot): Finding[] {
+ if (!table.rlsEnabled) return [];
+ const out: Finding[] = [];
+
+ const updateGrants = table.grants.filter(
+ (g) =>
+ g.privilege === 'UPDATE'
+ && g.role !== table.owner
+ && g.role !== 'PUBLIC'
+ && !g.bypassRls
+ );
+ if (updateGrants.length === 0) return [];
+
+ for (const grant of updateGrants) {
+ const usingCovered = table.policies.some((p) =>
+ policyProvidesClause(p, grant.role, 'UPDATE', 'USING')
+ );
+ if (!usingCovered) continue; // already reported as A4.
+
+ const withCheckCovered = table.policies.some((p) =>
+ policyProvidesClause(p, grant.role, 'UPDATE', 'WITH CHECK')
+ );
+ if (withCheckCovered) continue;
+
+ out.push({
+ code: 'A6',
+ severity: 'info',
+ category: 'coverage',
+ schema: table.schema,
+ table: table.name,
+ role: grant.role,
+ privilege: 'UPDATE',
+ message:
+ `Role ${grant.role} has UPDATE on ${table.schema}.${table.name}; USING is supplied but no applicable permissive policy provides WITH CHECK`,
+ hint: 'Without a WITH CHECK on UPDATE, rows can be updated to values that move them outside the role\'s visible set. Add WITH CHECK if the scoped columns are writable by this role.'
+ });
+ }
+
+ return out;
+}
+
+function policyProvidesClause(
+ p: PolicyInfo,
+ role: string,
+ privilege: PgPrivilege,
+ clause: ClauseKind
+): boolean {
+ if (!p.permissive) return false;
+ if (!POLICY_CMDS[p.cmd].includes(privilege)) return false;
+ if (!p.roles.includes('PUBLIC') && !p.roles.includes(role)) return false;
+ return clause === 'USING' ? p.using != null : p.withCheck != null;
+}
diff --git a/packages/safegres/src/checks/rls-flags.ts b/packages/safegres/src/checks/rls-flags.ts
new file mode 100644
index 000000000..39f10a19a
--- /dev/null
+++ b/packages/safegres/src/checks/rls-flags.ts
@@ -0,0 +1,81 @@
+import type { TableSnapshot } from '../pg/introspect';
+import type { Finding } from '../types';
+
+/**
+ * A1: RLS is enabled but there are zero policies on the table.
+ *
+ * Every non-owner query returns 0 rows. Usually a sign that someone ran
+ * `ALTER TABLE … ENABLE ROW LEVEL SECURITY` but never wrote the policies.
+ */
+export function checkRlsEnabledNoPolicies(table: TableSnapshot): Finding | null {
+ if (!table.rlsEnabled) return null;
+ if (table.policies.length > 0) return null;
+ return {
+ code: 'A1',
+ severity: 'critical',
+ category: 'flags',
+ schema: table.schema,
+ table: table.name,
+ message: `RLS is enabled on ${table.schema}.${table.name} but the table has zero policies`,
+ hint: 'Either add policies or disable RLS; otherwise non-owner queries return 0 rows (and writes by non-owners fail).'
+ };
+}
+
+/**
+ * A2: The table has non-trivial grants but RLS is disabled.
+ *
+ * Intent: if a real role (not just the owner or PUBLIC) can SELECT/INSERT/UPDATE/DELETE,
+ * we'd expect RLS to be on. Tables intentionally meant to be global (e.g. lookup tables)
+ * may be false positives; callers can exclude them via `--schemas`.
+ */
+export function checkGrantsWithoutRls(table: TableSnapshot): Finding | null {
+ if (table.rlsEnabled) return null;
+ const nonTrivialGrants = table.grants.filter((g) =>
+ g.role !== table.owner
+ && g.role !== 'PUBLIC'
+ && !g.bypassRls
+ && isWriteOrReadPrivilege(g.privilege)
+ );
+ if (nonTrivialGrants.length === 0) return null;
+
+ const roles = Array.from(new Set(nonTrivialGrants.map((g) => g.role))).sort();
+ return {
+ code: 'A2',
+ severity: 'high',
+ category: 'flags',
+ schema: table.schema,
+ table: table.name,
+ message:
+ `Roles [${roles.join(', ')}] have grants on ${table.schema}.${table.name} but RLS is disabled`,
+ hint: 'Every grantee can read/write every row. Enable RLS and add policies, or document the table as intentionally global.',
+ context: { roles }
+ };
+}
+
+/**
+ * A3: RLS is enabled but FORCE ROW LEVEL SECURITY is not set.
+ *
+ * Without FORCE, the table owner bypasses all policies. For anything with
+ * tenant data, that usually isn't what you want. Medium severity — owners
+ * bypassing policies is sometimes intentional (e.g. admin tooling).
+ */
+export function checkRlsNotForced(table: TableSnapshot): Finding | null {
+ if (!table.rlsEnabled) return null;
+ if (table.rlsForced) return null;
+ return {
+ code: 'A3',
+ severity: 'medium',
+ category: 'flags',
+ schema: table.schema,
+ table: table.name,
+ message: `RLS enabled on ${table.schema}.${table.name} but FORCE ROW LEVEL SECURITY is not set`,
+ hint: 'Without FORCE, the table owner bypasses all policies. Add `ALTER TABLE … FORCE ROW LEVEL SECURITY` unless you deliberately want owner bypass.'
+ };
+}
+
+function isWriteOrReadPrivilege(privilege: string): boolean {
+ return privilege === 'SELECT'
+ || privilege === 'INSERT'
+ || privilege === 'UPDATE'
+ || privilege === 'DELETE';
+}
diff --git a/packages/safegres/src/cli.ts b/packages/safegres/src/cli.ts
new file mode 100644
index 000000000..a0bb76369
--- /dev/null
+++ b/packages/safegres/src/cli.ts
@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+import { Logger } from '@pgpmjs/logger';
+import { CLI, CLIOptions, getPackageJson } from 'inquirerer';
+
+import { commands } from './cli/commands';
+
+const log = new Logger('safegres');
+
+if (process.argv.includes('--version') || process.argv.includes('-v')) {
+ const pkg = getPackageJson(__dirname);
+ process.stdout.write(`${pkg.version}\n`);
+ process.exit(0);
+}
+
+const options: Partial = {
+ minimistOpts: {
+ alias: { v: 'version', h: 'help' },
+ boolean: ['skip-ast', 'color', 'help', 'version'],
+ string: [
+ 'connection',
+ 'host',
+ 'user',
+ 'password',
+ 'database',
+ 'schemas',
+ 'exclude-schemas',
+ 'roles',
+ 'exclude-roles',
+ 'format',
+ 'fail-on'
+ ]
+ }
+};
+
+new CLI(commands, options).run().catch((error) => {
+ log.error(error);
+ process.exit(1);
+});
diff --git a/packages/safegres/src/cli/audit.ts b/packages/safegres/src/cli/audit.ts
new file mode 100644
index 000000000..78e7da952
--- /dev/null
+++ b/packages/safegres/src/cli/audit.ts
@@ -0,0 +1,117 @@
+import { Logger } from '@pgpmjs/logger';
+import { CLIOptions, Inquirerer, ParsedArgs } from 'inquirerer';
+import { Client } from 'pg';
+import { getPgEnvOptions, type PgConfig } from 'pg-env';
+
+import { audit } from '../commands/audit';
+import { renderJson } from '../report/json';
+import { renderPretty } from '../report/pretty';
+import type { Severity } from '../types';
+import { meetsThreshold, SEVERITY_ORDER } from '../types';
+
+const log = new Logger('safegres');
+
+const usage = `
+safegres audit — pure-PostgreSQL RLS auditor
+
+ safegres audit [OPTIONS]
+
+Connection (priority order, top wins):
+ --connection Full PostgreSQL connection string
+ --host PostgreSQL host (else PGHOST, default localhost)
+ --port PostgreSQL port (else PGPORT, default 5432)
+ --user PostgreSQL user (else PGUSER, default postgres)
+ --password PostgreSQL password (else PGPASSWORD,default password)
+ --database PostgreSQL database (else PGDATABASE,default postgres)
+
+Audit options:
+ --schemas Limit to these schemas (default: all non-system)
+ --exclude-schemas Skip these schemas
+ --roles Audit grants only for these roles (default: all)
+ --exclude-roles Skip grants for these roles
+ --format "pretty" (default) | "json" | "json-pretty"
+ --fail-on Exit non-zero if any finding >= severity
+ (critical|high|medium|low|info; default: none)
+ --skip-ast Skip AST-level anti-pattern checks (faster)
+ --no-color Disable ANSI colors in pretty output
+ --help, -h Show this help message
+`;
+
+function csvList(value: unknown): string[] | undefined {
+ if (typeof value !== 'string' || value.length === 0) return undefined;
+ return value
+ .split(',')
+ .map((p) => p.trim())
+ .filter(Boolean);
+}
+
+function buildClient(argv: ParsedArgs): Client {
+ if (typeof argv.connection === 'string' && argv.connection.length > 0) {
+ return new Client({ connectionString: argv.connection });
+ }
+ const overrides: Partial = {};
+ if (typeof argv.host === 'string') overrides.host = argv.host;
+ if (typeof argv.port === 'number') overrides.port = argv.port;
+ if (typeof argv.user === 'string') overrides.user = argv.user;
+ if (typeof argv.password === 'string') overrides.password = argv.password;
+ if (typeof argv.database === 'string') overrides.database = argv.database;
+ return new Client(getPgEnvOptions(overrides));
+}
+
+export default async (
+ argv: ParsedArgs,
+ _prompter: Inquirerer,
+ _options: CLIOptions
+): Promise => {
+ if (argv.help || argv.h) {
+ process.stdout.write(usage);
+ return;
+ }
+
+ // minimist parses `--no-color` as `color: false`.
+ const colorEnabled = argv.color !== false;
+
+ const client = buildClient(argv);
+ await client.connect();
+ try {
+ const report = await audit(client, {
+ schemas: csvList(argv.schemas),
+ excludeSchemas: csvList(argv['exclude-schemas']),
+ includeRoles: csvList(argv.roles),
+ excludeRoles: csvList(argv['exclude-roles']),
+ skipAstChecks: argv['skip-ast'] === true
+ });
+
+ const fmt = typeof argv.format === 'string' ? argv.format : 'pretty';
+ let output: string;
+ switch (fmt) {
+ case 'json':
+ output = renderJson(report);
+ break;
+ case 'json-pretty':
+ output = renderJson(report, { pretty: true });
+ break;
+ case 'pretty':
+ output = renderPretty(report, { color: colorEnabled });
+ break;
+ default:
+ log.error(`Unknown --format: ${fmt}`);
+ process.exit(2);
+ }
+ process.stdout.write(output);
+ process.stdout.write('\n');
+
+ const failOn = typeof argv['fail-on'] === 'string' ? (argv['fail-on'] as Severity) : undefined;
+ if (failOn) {
+ if (!(failOn in SEVERITY_ORDER)) {
+ log.error(`Unknown --fail-on severity: ${failOn}`);
+ process.exit(2);
+ }
+ if (report.findings.some((f) => meetsThreshold(f.severity, failOn))) {
+ process.exit(1);
+ }
+ }
+ } finally {
+ await client.end();
+ }
+};
diff --git a/packages/safegres/src/cli/commands.ts b/packages/safegres/src/cli/commands.ts
new file mode 100644
index 000000000..10e25af06
--- /dev/null
+++ b/packages/safegres/src/cli/commands.ts
@@ -0,0 +1,52 @@
+import { Logger } from '@pgpmjs/logger';
+import { CLIOptions, extractFirst, Inquirerer, ParsedArgs } from 'inquirerer';
+
+import audit from './audit';
+
+const log = new Logger('safegres');
+
+const usage = `
+safegres — pure-PostgreSQL RLS auditor
+
+Usage:
+ safegres [OPTIONS]
+
+Commands:
+ audit Audit grants, RLS flags, policy coverage, and anti-patterns
+ help Show this help message
+
+Run \`safegres --help\` for command-specific options.
+`;
+
+const commandMap: Record<
+ string,
+ (argv: ParsedArgs, prompter: Inquirerer, options: CLIOptions) => unknown
+> = {
+ audit
+};
+
+export const commands = async (
+ argv: ParsedArgs,
+ prompter: Inquirerer,
+ options: CLIOptions
+): Promise => {
+ const { first: command, newArgv } = extractFirst(argv);
+
+ if (!command && (argv.help || argv.h)) {
+ process.stdout.write(usage);
+ return;
+ }
+ if (!command || command === 'help') {
+ process.stdout.write(usage);
+ return;
+ }
+
+ const handler = commandMap[command];
+ if (!handler) {
+ log.error(`Unknown command: ${command}`);
+ process.stdout.write(usage);
+ process.exit(2);
+ }
+
+ await handler(newArgv as ParsedArgs, prompter, options);
+};
diff --git a/packages/safegres/src/commands/audit.ts b/packages/safegres/src/commands/audit.ts
new file mode 100644
index 000000000..fd11bb427
--- /dev/null
+++ b/packages/safegres/src/commands/audit.ts
@@ -0,0 +1,157 @@
+/**
+ * Script A driver: pure-Postgres RLS audit.
+ *
+ * Ingests a catalog snapshot, runs every check, and returns a structured report.
+ */
+
+import {
+ checkCoverageGaps,
+ checkUpdateWithCheckCoverage
+} from '../checks/coverage';
+import {
+ checkGrantsWithoutRls,
+ checkRlsEnabledNoPolicies,
+ checkRlsNotForced
+} from '../checks/rls-flags';
+import {
+ checkSessionUserGating,
+ checkTriviallyPermissive,
+ checkVolatileFunctions,
+ collectFunctionNames,
+ parseOrNull
+} from '../checks/anti-patterns';
+import { asExecutor, type IntrospectOptions, introspectTables, type QueryExecutor, type TableSnapshot } from '../pg/introspect';
+import { lookupVolatility, type ProcVolatility } from '../pg/proc';
+import { listAuditableRoles, resolveRoles } from '../pg/roles';
+import type { Finding, Report } from '../types';
+import { summarize } from '../types';
+
+import { version as PKG_VERSION } from '../version';
+
+export interface AuditOptions extends IntrospectOptions {
+ /** If provided, bypass `pg_roles` enumeration. Otherwise enumerate roles dynamically. */
+ includeRoles?: string[];
+ /** Roles to drop after enumeration. */
+ excludeRoles?: string[];
+ /**
+ * Skip AST-level anti-pattern checks (P1, P5). Useful for very fast audits
+ * that only want grants + RLS-flag + coverage findings.
+ */
+ skipAstChecks?: boolean;
+}
+
+export async function audit(
+ client: QueryExecutor,
+ options: AuditOptions = {}
+): Promise {
+ const exec = asExecutor(client);
+
+ // Resolve role set.
+ const allRoles = await listAuditableRoles(exec);
+ const resolution = resolveRoles(allRoles, options.includeRoles, options.excludeRoles);
+
+ const snapshot = await introspectTables(exec, {
+ schemas: options.schemas,
+ excludeSchemas: options.excludeSchemas,
+ roles: resolution.roles
+ });
+
+ const findings: Finding[] = [];
+
+ for (const table of snapshot) {
+ // --- RLS flags (structural) ---
+ const a1 = checkRlsEnabledNoPolicies(table);
+ if (a1) findings.push(a1);
+
+ const a2 = checkGrantsWithoutRls(table);
+ if (a2) findings.push(a2);
+
+ const a3 = checkRlsNotForced(table);
+ if (a3) findings.push(a3);
+
+ // --- Grant-vs-policy coverage ---
+ findings.push(...checkCoverageGaps(table));
+ findings.push(...checkUpdateWithCheckCoverage(table));
+
+ // --- AST-level anti-patterns ---
+ if (!options.skipAstChecks) {
+ findings.push(...(await auditTableAst(exec, table)));
+ }
+ }
+
+ findings.sort(compareFindings);
+
+ return {
+ version: PKG_VERSION,
+ generatedAt: new Date().toISOString(),
+ summary: summarize(findings),
+ findings
+ };
+}
+
+async function auditTableAst(
+ exec: QueryExecutor,
+ table: TableSnapshot
+): Promise {
+ if (table.policies.length === 0) return [];
+
+ // Collect all function names referenced across this table's policies so we
+ // can resolve volatility in one round-trip.
+ const funcNames: Array<{ schema?: string; name: string }> = [];
+ const parsed: Array<{
+ policy: (typeof table.policies)[number];
+ using: Awaited>;
+ withCheck: Awaited>;
+ }> = [];
+
+ for (const p of table.policies) {
+ const using = await parseOrNull(p.using, `${table.schema}.${table.name}.${p.name} USING`);
+ const withCheck = await parseOrNull(p.withCheck, `${table.schema}.${table.name}.${p.name} WITH CHECK`);
+ parsed.push({ policy: p, using, withCheck });
+ if (using) funcNames.push(...collectFunctionNames(using));
+ if (withCheck) funcNames.push(...collectFunctionNames(withCheck));
+ }
+
+ let volatility: Map;
+ try {
+ volatility = await lookupVolatility(exec, funcNames);
+ } catch {
+ volatility = new Map();
+ }
+
+ const findings: Finding[] = [];
+ for (const { policy, using, withCheck } of parsed) {
+ const trivial = checkTriviallyPermissive(table, policy, using, withCheck);
+ if (trivial) findings.push(trivial);
+ if (using) {
+ findings.push(...checkVolatileFunctions(table, using, volatility, policy.name));
+ findings.push(...checkSessionUserGating(table, using, policy.name));
+ }
+ if (withCheck) {
+ findings.push(...checkVolatileFunctions(table, withCheck, volatility, policy.name));
+ findings.push(...checkSessionUserGating(table, withCheck, policy.name));
+ }
+ }
+
+ return dedupe(findings);
+}
+
+function compareFindings(a: Finding, b: Finding): number {
+ const order: Record = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
+ if (order[a.severity] !== order[b.severity]) return order[a.severity] - order[b.severity];
+ if (a.schema !== b.schema) return (a.schema ?? '').localeCompare(b.schema ?? '');
+ if (a.table !== b.table) return (a.table ?? '').localeCompare(b.table ?? '');
+ return a.code.localeCompare(b.code);
+}
+
+function dedupe(findings: Finding[]): Finding[] {
+ const seen = new Set();
+ const out: Finding[] = [];
+ for (const f of findings) {
+ const key = [f.code, f.schema, f.table, f.policy, f.message].join('::');
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push(f);
+ }
+ return out;
+}
diff --git a/packages/safegres/src/index.ts b/packages/safegres/src/index.ts
new file mode 100644
index 000000000..1f6bc1a42
--- /dev/null
+++ b/packages/safegres/src/index.ts
@@ -0,0 +1,29 @@
+/**
+ * Public surface of `safegres` — a pure-PostgreSQL Row-Level-Security auditor.
+ *
+ * The auditor introspects pg_class / pg_policy / role grants and emits
+ * structured findings (A1–A7, P1, P5). It has no knowledge of any specific
+ * application schema or policy DSL.
+ */
+
+export { audit } from './commands/audit';
+export type { AuditOptions } from './commands/audit';
+export { renderJson } from './report/json';
+export { renderPretty } from './report/pretty';
+export * from './types';
+export {
+ introspectTables,
+ type IntrospectOptions,
+ type PgPrivilege,
+ type PolicyCmd,
+ type PolicyInfo,
+ type QueryExecutor,
+ type TableSnapshot
+} from './pg/introspect';
+export { listAuditableRoles, resolveRoles } from './pg/roles';
+export {
+ parsePolicyExpression,
+ PolicyParseError,
+ type PgAstNode,
+ type PolicyExpression
+} from './ast/parse';
diff --git a/packages/safegres/src/pg/introspect.ts b/packages/safegres/src/pg/introspect.ts
new file mode 100644
index 000000000..b81254efa
--- /dev/null
+++ b/packages/safegres/src/pg/introspect.ts
@@ -0,0 +1,285 @@
+/**
+ * Single-query catalog introspection for the safegres audit.
+ *
+ * Pulls every relation + every policy + (parsed) role ACL per role in one shot,
+ * so Script A can process the whole database from one in-memory snapshot.
+ */
+
+import type { Client, Pool } from 'pg';
+
+/** Minimal query interface — accepts pg.Client, pg.Pool, or any `{ query }`. */
+export interface QueryExecutor {
+ query(text: string, params?: unknown[]): Promise<{ rows: T[] }>;
+}
+
+/** PostgreSQL privilege codes as they appear in `pg_policy.polcmd` / ACL items. */
+export type PgPrivilege = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'TRUNCATE' | 'REFERENCES' | 'TRIGGER';
+
+/**
+ * Mapping of ACL single-letter codes (from `aclexplode`) to our privilege names.
+ * `r` = SELECT, `a` = INSERT, `w` = UPDATE, `d` = DELETE, etc.
+ */
+export const ACL_PRIVILEGE_MAP: Record = {
+ r: 'SELECT',
+ a: 'INSERT',
+ w: 'UPDATE',
+ d: 'DELETE',
+ D: 'TRUNCATE',
+ x: 'REFERENCES',
+ t: 'TRIGGER'
+};
+
+export interface GrantInfo {
+ role: string;
+ privilege: PgPrivilege;
+ /** Was the grant delegated (i.e. `WITH GRANT OPTION`). */
+ grantable: boolean;
+ /**
+ * `true` if the grantee role is a superuser or has `BYPASSRLS`. RLS policies
+ * do not apply to this role, so coverage findings are suppressed for it.
+ * `false` for `PUBLIC` and all other grantees.
+ */
+ bypassRls: boolean;
+}
+
+/** `pg_policy.polcmd` uses: `r` SELECT, `a` INSERT, `w` UPDATE, `d` DELETE, `*` ALL. */
+export type PolicyCmd = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL';
+
+export const POLICY_CMD_MAP: Record = {
+ r: 'SELECT',
+ a: 'INSERT',
+ w: 'UPDATE',
+ d: 'DELETE',
+ '*': 'ALL'
+};
+
+export interface PolicyInfo {
+ name: string;
+ cmd: PolicyCmd;
+ permissive: boolean;
+ /** Role names the policy applies to. `['PUBLIC']` if `polroles = {0}`. */
+ roles: string[];
+ using: string | null;
+ withCheck: string | null;
+}
+
+export interface TableSnapshot {
+ schema: string;
+ name: string;
+ oid: number;
+ rlsEnabled: boolean;
+ rlsForced: boolean;
+ /** `true` if the table is partitioned or is a view — used to skip irrelevant findings. */
+ isPartitioned: boolean;
+ owner: string;
+ grants: GrantInfo[];
+ policies: PolicyInfo[];
+}
+
+export interface IntrospectOptions {
+ /** Schemas to include. If omitted, all non-system schemas. */
+ schemas?: string[];
+ /**
+ * Schemas to exclude. Applied on top of `schemas`.
+ * Defaults to `['pg_catalog', 'information_schema', 'pg_toast']`.
+ */
+ excludeSchemas?: string[];
+ /**
+ * Roles to include when reporting grants. If omitted, all roles returned
+ * by {@link listAuditableRoles}.
+ */
+ roles?: string[];
+}
+
+const DEFAULT_EXCLUDES = ['pg_catalog', 'information_schema', 'pg_toast'];
+
+/**
+ * One-query snapshot of every table + its policies + its grants expanded per role.
+ *
+ * We deliberately avoid views that hide permissive vs restrictive distinctions
+ * (like `pg_policies`) — `pg_policy` gives us `polpermissive` directly.
+ */
+export async function introspectTables(
+ exec: QueryExecutor,
+ options: IntrospectOptions = {}
+): Promise {
+ const excludes = [...DEFAULT_EXCLUDES, ...(options.excludeSchemas ?? [])];
+
+ const roleFilter = options.roles && options.roles.length > 0
+ ? `AND (CASE WHEN g.grantee_oid = 0 THEN 'PUBLIC' ELSE rol.rolname END) = ANY($3::text[])`
+ : '';
+ const schemaFilter = options.schemas && options.schemas.length > 0
+ ? `AND n.nspname = ANY($1::text[])`
+ : `AND NOT (n.nspname = ANY($2::text[]))`;
+
+ // We reference every param at least once (even if trivially) so Postgres
+ // can infer types for all three. Unused parameters otherwise error with
+ // "could not determine data type of parameter $N".
+ const sql = `
+ WITH _params AS (
+ SELECT
+ $1::text[] AS include_schemas,
+ $2::text[] AS exclude_schemas,
+ $3::text[] AS role_filter
+ ),
+ rels AS (
+ SELECT
+ n.nspname AS schema_name,
+ c.relname AS table_name,
+ c.oid AS oid,
+ c.relrowsecurity AS rls_enabled,
+ c.relforcerowsecurity AS rls_forced,
+ (c.relkind = 'p') AS is_partitioned,
+ pg_catalog.pg_get_userbyid(c.relowner) AS owner,
+ c.relacl AS relacl
+ FROM pg_class c
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE c.relkind IN ('r', 'p')
+ ${schemaFilter}
+ ),
+ grants_exploded AS (
+ SELECT
+ r.oid,
+ (aclexplode(r.relacl)).grantee AS grantee_oid,
+ (aclexplode(r.relacl)).privilege_type AS privilege_type,
+ (aclexplode(r.relacl)).is_grantable AS is_grantable
+ FROM rels r
+ WHERE r.relacl IS NOT NULL
+ ),
+ grants AS (
+ SELECT
+ g.oid,
+ CASE WHEN g.grantee_oid = 0 THEN 'PUBLIC' ELSE rol.rolname END AS grantee,
+ g.privilege_type,
+ g.is_grantable,
+ CASE
+ WHEN g.grantee_oid = 0 THEN false
+ ELSE COALESCE(rol.rolsuper OR rol.rolbypassrls, false)
+ END AS bypass_rls
+ FROM grants_exploded g
+ LEFT JOIN pg_roles rol ON rol.oid = g.grantee_oid
+ WHERE true
+ ${roleFilter}
+ ),
+ policies AS (
+ SELECT
+ p.polrelid AS oid,
+ p.polname AS name,
+ p.polcmd AS cmd,
+ p.polpermissive AS permissive,
+ CASE
+ WHEN p.polroles = ARRAY[0]::oid[] THEN ARRAY['PUBLIC']
+ ELSE COALESCE(
+ (SELECT array_agg(rolname) FROM pg_roles WHERE oid = ANY(p.polroles)),
+ ARRAY[]::text[]
+ )
+ END AS roles,
+ pg_get_expr(p.polqual, p.polrelid) AS using_expr,
+ pg_get_expr(p.polwithcheck, p.polrelid) AS with_check_expr
+ FROM pg_policy p
+ )
+ SELECT
+ r.schema_name,
+ r.table_name,
+ r.oid::int AS oid,
+ r.rls_enabled,
+ r.rls_forced,
+ r.is_partitioned,
+ r.owner,
+ COALESCE(
+ (SELECT jsonb_agg(jsonb_build_object(
+ 'role', g.grantee,
+ 'privilege', g.privilege_type,
+ 'grantable', g.is_grantable,
+ 'bypassRls', g.bypass_rls
+ )) FROM grants g WHERE g.oid = r.oid),
+ '[]'::jsonb
+ ) AS grants,
+ COALESCE(
+ (SELECT jsonb_agg(jsonb_build_object(
+ 'name', p.name,
+ 'cmd', p.cmd::text,
+ 'permissive', p.permissive,
+ 'roles', to_jsonb(p.roles),
+ 'using', p.using_expr,
+ 'withCheck', p.with_check_expr
+ )) FROM policies p WHERE p.oid = r.oid),
+ '[]'::jsonb
+ ) AS policies
+ FROM rels r
+ ORDER BY r.schema_name, r.table_name
+ `;
+
+ const params: unknown[] = [
+ options.schemas ?? [],
+ excludes,
+ options.roles ?? []
+ ];
+
+ const { rows } = await exec.query<{
+ schema_name: string;
+ table_name: string;
+ oid: number;
+ rls_enabled: boolean;
+ rls_forced: boolean;
+ is_partitioned: boolean;
+ owner: string;
+ grants: Array<{ role: string; privilege: string; grantable: boolean; bypassRls: boolean }>;
+ policies: Array<{
+ name: string;
+ cmd: string;
+ permissive: boolean;
+ roles: string[];
+ using: string | null;
+ withCheck: string | null;
+ }>;
+ }>(sql, params);
+
+ return rows.map((r) => ({
+ schema: r.schema_name,
+ name: r.table_name,
+ oid: r.oid,
+ rlsEnabled: r.rls_enabled,
+ rlsForced: r.rls_forced,
+ isPartitioned: r.is_partitioned,
+ owner: r.owner,
+ grants: r.grants.map((g) => ({
+ role: g.role,
+ privilege: normalizePrivilege(g.privilege),
+ grantable: g.grantable,
+ bypassRls: g.bypassRls === true
+ })),
+ policies: r.policies.map((p) => ({
+ name: p.name,
+ cmd: POLICY_CMD_MAP[p.cmd] ?? 'ALL',
+ permissive: p.permissive,
+ roles: p.roles,
+ using: p.using,
+ withCheck: p.withCheck
+ }))
+ }));
+}
+
+function normalizePrivilege(raw: string): PgPrivilege {
+ const upper = raw.toUpperCase();
+ switch (upper) {
+ case 'SELECT':
+ case 'INSERT':
+ case 'UPDATE':
+ case 'DELETE':
+ case 'TRUNCATE':
+ case 'REFERENCES':
+ case 'TRIGGER':
+ return upper;
+ default:
+ // `r`, `a`, `w`, `d` — ACL single-letter form
+ return ACL_PRIVILEGE_MAP[raw] ?? 'SELECT';
+ }
+}
+
+/**
+ * Convenience: accept any of `pg.Client` / `pg.Pool` / a custom executor.
+ */
+export function asExecutor(client: Client | Pool | QueryExecutor): QueryExecutor {
+ return client as QueryExecutor;
+}
diff --git a/packages/safegres/src/pg/proc.ts b/packages/safegres/src/pg/proc.ts
new file mode 100644
index 000000000..dca9325b1
--- /dev/null
+++ b/packages/safegres/src/pg/proc.ts
@@ -0,0 +1,81 @@
+import type { QueryExecutor } from './introspect';
+
+/** `pg_proc.provolatile`: `i` immutable, `s` stable, `v` volatile. */
+export type Volatility = 'i' | 's' | 'v';
+
+export interface ProcVolatility {
+ /** Schema-qualified name, or bare name if in `pg_catalog` / a common system schema. */
+ name: string;
+ volatility: Volatility;
+ isSecurityDefiner: boolean;
+ /** True if this is a built-in / system function (we usually allow-list these). */
+ isSystem: boolean;
+}
+
+const SYSTEM_SCHEMAS = new Set([
+ 'pg_catalog',
+ 'information_schema',
+ 'pg_toast'
+]);
+
+/**
+ * Look up `(schema, name)` tuples in pg_proc and return their volatility.
+ * Returns a map keyed by the two names the AST walker might see:
+ * - `schema.name` (schema-qualified)
+ * - `name` (unqualified; only used when the function lives in `pg_catalog`)
+ */
+export async function lookupVolatility(
+ exec: QueryExecutor,
+ names: Array<{ schema?: string; name: string }>
+): Promise