Skip to content

Commit 0922b73

Browse files
committed
feat(safegres): pure-Postgres RLS auditor package
Pure-PostgreSQL Row-Level-Security auditor with zero application dependencies. Re-exports the node-type-registry Authz*/Data*/Relation* type definitions so consumers writing auditors on top of Constructive's type system can stay on a single dependency. Detects: - A1: RLS enabled but no policies (deny-all) - A2: grants on tables with RLS disabled - A3: RLS enabled but FORCE not set (owner bypass) - A4: write grants without covering policy - A5: read grants without covering policy - A6: UPDATE coverage missing WITH CHECK - A7: trivially-permissive policy (USING (true)) - P1: volatile function in policy body - P5: session_user / pg_has_role gating Coverage is aggregated per (table, role) across applicable permissive policies (FOR ALL, PUBLIC role considered). BYPASSRLS roles suppressed. Adds graphql/safegres to the run-tests CI matrix.
1 parent 90b1554 commit 0922b73

36 files changed

Lines changed: 4834 additions & 7466 deletions

.github/workflows/run-tests.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ jobs:
8787
env: {}
8888
- package: graphql/playwright-test
8989
env: {}
90+
- package: graphql/safegres
91+
env: {}
9092
# - package: jobs/knative-job-worker
9193
# env: {}
9294
- package: packages/csv-to-pg

graphql/safegres/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# safegres
2+
3+
Pure-PostgreSQL Row-Level-Security auditor. Zero application dependencies — drop it on any Postgres database and get a structured report of grants, RLS flags, policy coverage, and AST-level anti-patterns.
4+
5+
```bash
6+
npm install -g safegres
7+
safegres pg --connection postgresql://localhost/mydb
8+
```
9+
10+
## What it checks
11+
12+
| Code | Severity | Category | Check |
13+
| --- | --- | --- | --- |
14+
| A1 | critical | flags | RLS enabled but **0 policies** (effectively deny-all) |
15+
| A2 | high | flags | Grants exist on a table with **RLS disabled** |
16+
| A3 | medium | flags | RLS enabled but **`FORCE ROW LEVEL SECURITY` not set** (table owner bypass) |
17+
| A4 | high | coverage | INSERT / UPDATE / DELETE grant with **no covering policy** for that verb |
18+
| A5 | medium | coverage | SELECT grant with **no policy** (silent empty result) |
19+
| A6 | info | coverage | UPDATE has `USING` but **no `WITH CHECK`** (row-smuggling surface) |
20+
| A7 | high | anti-pattern | Trivially-permissive policy (`USING (true)` / `WITH CHECK (true)`) |
21+
| P1 | high | anti-pattern | Policy body calls a **VOLATILE function** (per-row evaluation) |
22+
| P5 | high | anti-pattern | Policy body references **`session_user`** / `current_user` / `pg_has_role(...)` |
23+
24+
Coverage is aggregated `(table, role) → { hasUsing, hasWithCheck }` across every applicable permissive policy (FOR ALL + PUBLIC-role policies considered). Roles with `BYPASSRLS` are suppressed.
25+
26+
## Library use
27+
28+
```ts
29+
import { Client } from 'pg';
30+
import { auditPg, renderPretty } from 'safegres';
31+
32+
const client = new Client({ connectionString: process.env.DATABASE_URL });
33+
await client.connect();
34+
35+
const report = await auditPg(client, {
36+
excludeSchemas: ['my_private_schema']
37+
});
38+
39+
console.log(renderPretty(report));
40+
console.log(`${report.findings.length} findings`);
41+
```
42+
43+
## Authz* type re-exports
44+
45+
`safegres` re-exports the [`node-type-registry`](../node-type-registry) Authz* / Data* / Relation* / View* type registry so consumers building auditors on top of Constructive's type system can stay on a single dependency:
46+
47+
```ts
48+
import { AuthzDirectOwner, type NodeTypeDefinition } from 'safegres';
49+
```
50+
51+
## License
52+
53+
MIT.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
-- A1 seed: RLS enabled on a table with zero policies.
2+
-- Expected finding: A1 (critical)
3+
4+
CREATE SCHEMA IF NOT EXISTS fx_a1;
5+
6+
CREATE TABLE fx_a1.posts (
7+
id bigserial PRIMARY KEY,
8+
body text
9+
);
10+
11+
ALTER TABLE fx_a1.posts ENABLE ROW LEVEL SECURITY;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- A2 seed: grants to a non-owner role on a table with RLS disabled.
2+
-- Expected finding: A2 (high)
3+
4+
CREATE SCHEMA IF NOT EXISTS fx_a2;
5+
6+
DO $$ BEGIN
7+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a2_reader') THEN
8+
CREATE ROLE fx_a2_reader NOLOGIN;
9+
END IF;
10+
END $$;
11+
12+
CREATE TABLE fx_a2.widgets (
13+
id bigserial PRIMARY KEY,
14+
body text
15+
);
16+
17+
GRANT SELECT, INSERT ON fx_a2.widgets TO fx_a2_reader;
18+
-- RLS intentionally NOT enabled.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- A3 seed: RLS enabled but not FORCE'd, with at least one policy so A1 doesn't trigger.
2+
-- Expected finding: A3 (medium)
3+
4+
CREATE SCHEMA IF NOT EXISTS fx_a3;
5+
6+
CREATE TABLE fx_a3.notes (
7+
id bigserial PRIMARY KEY,
8+
body text,
9+
owner_id uuid
10+
);
11+
12+
ALTER TABLE fx_a3.notes ENABLE ROW LEVEL SECURITY;
13+
14+
CREATE POLICY notes_select ON fx_a3.notes
15+
FOR SELECT USING (true);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
-- A4 seed: INSERT grant to a role, but only a SELECT policy exists for that role.
2+
-- Expected findings: A4 (high), and the INSERT will fail at runtime.
3+
4+
CREATE SCHEMA IF NOT EXISTS fx_a4;
5+
6+
DO $$ BEGIN
7+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a4_writer') THEN
8+
CREATE ROLE fx_a4_writer NOLOGIN;
9+
END IF;
10+
END $$;
11+
12+
CREATE TABLE fx_a4.events (
13+
id bigserial PRIMARY KEY,
14+
body text,
15+
owner_id uuid
16+
);
17+
18+
ALTER TABLE fx_a4.events ENABLE ROW LEVEL SECURITY;
19+
20+
GRANT SELECT, INSERT ON fx_a4.events TO fx_a4_writer;
21+
22+
CREATE POLICY events_select ON fx_a4.events
23+
FOR SELECT TO fx_a4_writer
24+
USING (true);
25+
-- No INSERT policy for fx_a4_writer.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- A6 seed: UPDATE policy with USING but no WITH CHECK.
2+
-- Expected finding: A6 (high)
3+
4+
CREATE SCHEMA IF NOT EXISTS fx_a6;
5+
6+
DO $$ BEGIN
7+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a6_editor') THEN
8+
CREATE ROLE fx_a6_editor NOLOGIN;
9+
END IF;
10+
END $$;
11+
12+
CREATE TABLE fx_a6.docs (
13+
id bigserial PRIMARY KEY,
14+
owner_id uuid NOT NULL,
15+
body text
16+
);
17+
18+
ALTER TABLE fx_a6.docs ENABLE ROW LEVEL SECURITY;
19+
20+
GRANT SELECT, UPDATE ON fx_a6.docs TO fx_a6_editor;
21+
22+
CREATE POLICY docs_update ON fx_a6.docs
23+
FOR UPDATE TO fx_a6_editor
24+
USING (true);
25+
-- missing WITH CHECK — rows can be moved out of the visible set.
26+
27+
CREATE POLICY docs_select ON fx_a6.docs
28+
FOR SELECT TO fx_a6_editor USING (true);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- A7 seed: permissive policy whose body is the literal `true` —
2+
-- effectively "RLS enabled, nothing gated" ⇒ fail-open.
3+
-- Expected finding: A7 (high)
4+
5+
CREATE SCHEMA IF NOT EXISTS fx_a7;
6+
7+
DO $$ BEGIN
8+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_a7_reader') THEN
9+
CREATE ROLE fx_a7_reader;
10+
END IF;
11+
END $$;
12+
13+
CREATE TABLE fx_a7.posts (
14+
id bigserial PRIMARY KEY,
15+
body text
16+
);
17+
18+
ALTER TABLE fx_a7.posts ENABLE ROW LEVEL SECURITY;
19+
ALTER TABLE fx_a7.posts FORCE ROW LEVEL SECURITY;
20+
21+
CREATE POLICY fx_a7_open ON fx_a7.posts FOR SELECT TO fx_a7_reader USING (true);
22+
23+
GRANT USAGE ON SCHEMA fx_a7 TO fx_a7_reader;
24+
GRANT SELECT ON fx_a7.posts TO fx_a7_reader;
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
-- Baseline: correctly-configured table with RLS + FORCE + grants covered by
2+
-- non-trivial policies.
3+
-- Expected findings: none.
4+
5+
CREATE SCHEMA IF NOT EXISTS fx_clean;
6+
7+
DO $$ BEGIN
8+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_clean_user') THEN
9+
CREATE ROLE fx_clean_user NOLOGIN;
10+
END IF;
11+
END $$;
12+
13+
CREATE TABLE fx_clean.items (
14+
id bigserial PRIMARY KEY,
15+
owner_id uuid NOT NULL,
16+
body text
17+
);
18+
19+
ALTER TABLE fx_clean.items ENABLE ROW LEVEL SECURITY;
20+
ALTER TABLE fx_clean.items FORCE ROW LEVEL SECURITY;
21+
22+
GRANT SELECT, INSERT, UPDATE ON fx_clean.items TO fx_clean_user;
23+
24+
-- Non-trivial policies gated on the row owner column.
25+
CREATE POLICY items_select ON fx_clean.items
26+
FOR SELECT TO fx_clean_user
27+
USING (owner_id IS NOT NULL);
28+
29+
CREATE POLICY items_insert ON fx_clean.items
30+
FOR INSERT TO fx_clean_user
31+
WITH CHECK (owner_id IS NOT NULL);
32+
33+
CREATE POLICY items_update ON fx_clean.items
34+
FOR UPDATE TO fx_clean_user
35+
USING (owner_id IS NOT NULL) WITH CHECK (owner_id IS NOT NULL);
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- P1 seed: policy references a user-defined VOLATILE function.
2+
-- Expected finding: P1 (high) — per-row evaluation.
3+
4+
CREATE SCHEMA IF NOT EXISTS fx_p1;
5+
6+
DO $$ BEGIN
7+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fx_p1_user') THEN
8+
CREATE ROLE fx_p1_user NOLOGIN;
9+
END IF;
10+
END $$;
11+
12+
CREATE FUNCTION fx_p1.slow_auth_lookup() RETURNS uuid
13+
LANGUAGE sql
14+
VOLATILE
15+
AS $$ SELECT gen_random_uuid() $$;
16+
17+
CREATE TABLE fx_p1.records (
18+
id bigserial PRIMARY KEY,
19+
owner_id uuid
20+
);
21+
22+
ALTER TABLE fx_p1.records ENABLE ROW LEVEL SECURITY;
23+
GRANT SELECT ON fx_p1.records TO fx_p1_user;
24+
25+
CREATE POLICY records_select ON fx_p1.records
26+
FOR SELECT TO fx_p1_user
27+
USING (owner_id = fx_p1.slow_auth_lookup());

0 commit comments

Comments
 (0)