db-firewall 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dragus Enterprises
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,106 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # db-firewall (CLI)
2
+
3
+ Install, scan and verify DB Firewall's tenant isolation on PostgreSQL, from Node. No Python needed.
4
+
5
+ ```bash
6
+ export DB_FIREWALL_TENANT_KEY=$(openssl rand -hex 32) # the app uses the same key
7
+ npx db-firewall migrate --url "$ADMIN_URL" # install the RLS policies and the key
8
+ npx db-firewall scan --url "$DATABASE_URL" --strict # CI gate: exit 1 if a table is unprotected
9
+ npx db-firewall verify --url "$APP_URL" --tenant acme # test isolation as your app's own role
10
+ npx db-firewall key --url "$ADMIN_URL" # print the database's key
11
+ ```
12
+
13
+ ## migrate
14
+
15
+ Runs `migrations/001_postgresql.sql` (shipped unchanged in this package) as an admin role. It protects every table in the schema that has the tenant column, and is safe to re-run after you add tables.
16
+
17
+ - `--column tenant_id`: the tenant column.
18
+ - `--schema public`: the schema.
19
+ - `--mode permissive|restrictive`: use `restrictive` when your app has RLS policies of its own (Supabase, Drizzle `pgPolicy`). The tenant policies are then AND-ed with them. See the main README, "Run the migration".
20
+ - `--harden-signals`: revoke `pg_terminate_backend` and `pg_cancel_backend` from `PUBLIC` in this database, so tenants sharing the app role cannot end or cancel each other's requests. It covers every app and role in the database, so it is off by default and needs a superuser.
21
+ - `--harden --app-role <role>`: also revoke what `verify` fails on that the migration can remove, from `PUBLIC` and the app role, whoever granted it: `CREATE` on the database and on every schema, `TRIGGER` and `TRUNCATE` on every table and in default privileges (so tables created later do not get them), `pg_stat_activity` with `pg_stat_get_activity` and `pg_stat_get_backend_activity` (all tenants share one role, so its sessions see each other's SQL text), and the backend signals (as `--harden-signals`), and `pg_notify()` (any session can send any payload to every listener in the database; the `NOTIFY` statement needs no grant, and roles that call `pg_notify` need `GRANT EXECUTE` again). `SELECT`, `INSERT`, `UPDATE` and `DELETE` stay. It covers the whole database, so it is off by default and needs a superuser. It refuses, and changes nothing, when the app role can act as the owner of the database or of any object in it (an owner can grant itself everything back: give them to another role first), or still has one of these privileges through a role it is a member of. `CASCADE` also removes what the app role passed on with a grant option. Roles that only have `pg_monitor` lose `pg_stat_activity` too: grant it to them again. Default privileges granted to a group role the app is in are not changed; `verify` fails on what they grant. Give the bare role name, without quotes. Run it again after granting new privileges (`GRANT ALL` includes `TRIGGER` and `TRUNCATE`).
22
+ - `--ddl-guard`: install the DDL guard, two event triggers in this database that run at the end of every DDL command. Aft A table with the tenant column that has no firewall policies (created, renamed, given the column later, a new partition or child, or a typed table changed by `ALTER TYPE ... CASCADE`) is protected at the end of the command, the way the migration would, so it is never open until the next run. A table with a permissive policy of its own, in permissive mode, is refused instead. A command that weakens a protected table (one with firewall policies) fails: RLS turned off or not forced, a firewall policy dropped, renamed or altered (also by dropping the tenant column with `CASCADE`), another permissive policy in permissive mode (it ORs with the tenant policies), the table taken out of what the migration protects (moved to another schema, renamed to a name the migration skips, its tenant column renamed, its schema renamed), or given a parent, child or partition that is not protected, either way round (`INHERIT`, `ATTACH PARTITION`, `PARTITION OF` in another schema or as a foreign table: reading the parent shows the child's rows, and a partition gets the rows written to the parent). A rule on a protected table fails. Policy names `fw_<hash>_*` are the firewall's: no command may create one, rename a policy to one, or alter one. `TRIGGER` and `TRUNCATE` granted to another role are revoked again. A renamed table gets the policies of its new name. PostgreSQL fires no event trigger for `EXPLAIN ANALYZE CREATE TABLE AS` or `EXPLAIN ANALYZE SELECT INTO`: a table made that way stays open until the next DDL command in the database (any command protects it), and `verify` fails on it until then. With default privileges that grant the app role access to new tables, the app can read and write such a table until then. So the guard is a safety net for migrations, not a boundary against a role that holds `CREATE` on a protected schema and means harm: keep `CREATE` there to the roles that run migrations. The guard refuses an object of another role in schema `db_firewall`, or named `public._firewall_*`. Not stopped: code the table's owner attaches to it (a trigger, a CHECK constraint, a default, a generated column, an index expression, a domain constraint) runs on every tenant's writes, and can copy them out. Dropping the table is fine. It binds every role, table owners included, but a superuser and the role that owns the migration's objects: only they can skip it with `SET dbfw.ddl_guard_skip = 'on'` (the migration sets it for itself), and only a superuser can remove it (`DROP EVENT TRIGGER _firewall_ddl_guard, _firewall_ddl_drop_guard`). A later run without the flag leaves it in place. It needs a superuser. `verify` reports whether it is on.
23
+ - `--context signed|plain`: `signed` (default) makes the policies accept a tenant only with a signature made with the key for the current transaction, so SQL the app runs cannot switch tenants. `DB_FIREWALL_TENANT_KEY`, when set, is installed (or rotated to); otherwise the database keeps its key, or generates one on the first run (`db-firewall key` prints it). See the main README, "PostgreSQL tenant context".
24
+
25
+ After the migration it prints the scan.
26
+
27
+ ## scan
28
+
29
+ Lists every table in the schema that has the tenant column, and reports whether it is protected. Protected means all of these:
30
+
31
+ - RLS is enabled and forced.
32
+ - All four firewall policies exist.
33
+ - Each policy applies to `PUBLIC`, covers its own command, and uses exactly the migration's predicate, read with `search_path = pg_catalog`, so an operator planted in another schema shows. The CLI asks PostgreSQL to deparse that predicate on a temporary copy of the table, which is rolled back.
34
+ - In permissive mode, no other permissive policy exists on the table.
35
+ - The firewall functions match the migration, schema `db_firewall` and every object in it have one owner, and no `public._firewall_*` object is left (see verify, 2).
36
+ - The policies use the expected tenant context, `--context signed` (the default) or `plain`. `tenant_context` in the report names the one they use.
37
+
38
+ `--strict` exits 1 when a table is unprotected or no table is found (a wrong `--column` or `--schema`). `--json` prints `{ tables, excluded }`. Each table report has the Python scanner's fields (`hooks/scanner.py`) plus `schema` and `owner`. When the predicate check cannot run (a read-only transaction, no `TEMP` privilege, or no `SELECT` on the table), the report says why in `predicate_check`, and the table counts as unprotected.
39
+
40
+ `--schema` is repeatable. `--exclude <glob>` (repeatable, `*`, `?`, `[abc]`) replaces the default exclusions, which are exactly the tables the migration skips: `_firewall_*` and the migration-history tables (`schema_migrations`, `alembic_version`, `knex_migrations`, `prisma_migrations`, `__diesel_schema_migrations`, `flyway_schema_history`). Excluded tables are always listed in the output.
41
+
42
+ ## verify
43
+
44
+ The checks are the shared queries in `checks/postgresql.sql` (shipped as `sql/checks_postgresql.sql`); `python -m hooks.verify` in the repository runs the same file, with the same results.
45
+
46
+ Connect with your app's own URL and role, not an admin's, with `DB_FIREWALL_TENANT_KEY` set to the key the app uses (exit 2 without it). `verify` tests behaviour, not only the policies. It is read-only by default: each check runs in a transaction that is rolled back.
47
+
48
+ 1. **The role:** RLS applies to it and to every role it is a member of. None is a superuser, has `BYPASSRLS`, is in `_firewall_bypass_roles`, or is a member of `pg_read_all_data`, `pg_write_all_data`, `pg_execute_server_program`, `pg_read_server_files` or `pg_write_server_files`. The last three run programs or read and write files as the server.
49
+ 2. **Firewall objects:** the functions the policies, the signing and the session reset use (`_firewall_is_bypass_role`, `_firewall_tenant`, `_firewall_sign`, `_firewall_nonce`, `_firewall_reset_session`, `_firewall_session_settings`) match the migration (body, `SECURITY DEFINER`, settings, volatility), and every `db_firewall._firewall_*` object has one owner. `scan` checks this too. Every function the migration creates is checked, and an extra `db_firewall._firewall_*` function fails. No role the app can use can `CREATE` in any schema or owns one, or the database (it could plant objects or operators, and reach them through a `search_path` it sets for itself with `ALTER ROLE ... SET`), or has `TRIGGER` on a table or owns one (a deferred trigger runs at COMMIT). No role the app can use can read `_firewall_keys` or execute `_firewall_sign` or `_firewall_set_tenant`. The role, and every role it can `SET ROLE` to, cannot write `_firewall_bypass_roles` or `_firewall_tenant_map`, column grants included (else it could add itself as a bypass role). It cannot act as the owner of schema `public` or of any `_firewall_*` object (else it could drop and replace the bypass list), nor of the database or any object in it, temporary objects and large objects aside (an owner can change a domain constraint or a function body that runs in other tenants' sessions, or grant itself its rights back). On PostgreSQL 15 and later the database owner owns `public`, so do not make the app role the database owner.
50
+ 3. **Tables found:** at least one table has the tenant column.
51
+ 4. **Per table:**
52
+ - the scan above passes;
53
+ - the role does not own the table (an owner can turn RLS off);
54
+ - the role has no `TRUNCATE` (RLS does not apply to it);
55
+ - with no tenant, the role sees no rows;
56
+ - with the tenant set but not signed, as SQL the app runs could set it, the role sees no rows (this fails on a plain context);
57
+ - as `--tenant`, signed with the key, every visible row belongs to that tenant.
58
+ 5. **Tenant has rows:** `--tenant` sees at least one row somewhere. A tenant with no rows proves nothing, so this fails.
59
+ 6. **Read paths RLS does not guard,** in any schema, for the role and every role it can `SET ROLE` to: no foreign table (RLS does not reach remote data). No materialized view with the tenant column or built over a function. No view or materialized view the role can read (a column grant is enough) whose chain down to a tenant table holds a materialized view or a view that runs as an owner who skips RLS (use `security_invoker = true`). No `SECURITY DEFINER` function owned by such a role that the app can execute, or that PostgreSQL runs without checking `EXECUTE`: a trigger or event trigger function, an aggregate's or range type's support function, index support (an operator family's operators too), a selectivity estimator, planner support, type I/O, an encoding conversion, a transform, a text search parser or template, or a foreign-data wrapper's or language's handler or validator. A function body is not inspected, so every such function counts, except the firewall's own. No `LEAKPROOF` function outside `pg_catalog` (it runs before the RLS filter and can leak the rows it sees). No rule other than a view's own, on any table or view (a rule's actions run as the table owner, and a write reaches a table through a view, a column grant or a cascade): use a trigger instead.
60
+ 7. **Other schemas:** no table with the tenant column that the role can read (a column grant is enough) outside `--schema`.
61
+
62
+ A table no usable role can `SELECT` (such as a partition read through its parent) gets no row checks: there are no direct reads, and the other checks cover the rest. A table readable only after `SET ROLE` is inconclusive: run `verify` as that role. So is a row check whose query fails. A global check whose query fails counts as failed.
63
+
64
+ `--write-probe` also tries, per table, to insert a row for another tenant, which RLS must refuse. It is rolled back, but it fires `BEFORE INSERT` triggers and uses up a sequence value, so it is off by default. A refusal by anything other than RLS is inconclusive.
65
+
66
+ ### Strict mode (`--strict`)
67
+
68
+ The checks above look for known leak paths. `--strict` turns that into an allowlist, so unknown paths fail too. Everything a role the app can use can read must be protected tenant data:
69
+
70
+ - a table the migration protects;
71
+ - a `security_invoker` view that reads at least one table, calls no function outside `pg_catalog`, and whose chain reaches only protected tables (no materialized view, foreign table or definer view on the way). Constants in a view body (`VALUES`, literals) cannot be seen;
72
+ - a sequence a protected table owns (`serial`, identity). Its value is shared: `nextval` shows how many rows all tenants have inserted.
73
+
74
+ Anything else the app can read fails: a table without the tenant column (an audit log, a history table, a reference table), an invoker view over one or over constants, a standalone sequence, a foreign table. Also:
75
+
76
+ - no large object may be readable (they are one store for the whole database, with no tenant);
77
+ - the role may not read other sessions' SQL in `pg_stat_activity` or `pg_stat_statements` (literal values in another tenant's queries). `migrate --harden` revokes it. On managed PostgreSQL only the provider's superuser can, so `--allow-query-text` turns this check into an information line: send values as parameters. Without `--strict` it is an information line. With `--allow-query-text`, `pg_stat_statements`' own views (`pg_stat_statements`, `pg_stat_statements_info`) are accepted as readable too;
78
+ - no `SECURITY DEFINER` function outside the firewall's own may exist, executable or not, because it runs as its owner, whatever it reads, and PostgreSQL runs some functions (triggers, aggregate, range and index support, type I/O) without checking `EXECUTE`.
79
+
80
+ `--allow-global` (repeatable, `*`, `?` and `[abc]` globs; it needs `--strict`) marks data every tenant may share, such as a list of countries: `schema.name` for a table, view or sequence, `schema.name(argtypes)` for a function, as verify prints it (`app.f(*)` for every overload, and for array types; `app.*` never clears a function). In the bypass-objects check it clears only functions, such as a platform's own definer functions, and foreign tables without the tenant column; never a view over a tenant table or a rule. Allowlist deliberately: an allowlisted table that holds tenant data is a leak.
81
+
82
+ Strict mode closes most of what "What a pass means" lists below: tenant data in tables without the tenant column, and copies into readable tables, now fail unless you allowlist them.
83
+
84
+ ### What a pass means
85
+
86
+ A pass means `verify` found no known leak path. It does not prove isolation. It does not see:
87
+
88
+ - **Tenant data in tables without the tenant column,** such as child tables, audit logs or history tables. Give every table that holds tenant data the tenant column, so the migration protects it. `--strict` fails on such tables unless you allowlist them.
89
+ - **Copies made by triggers, rules or jobs** into a table the app can read, even a plain (not `SECURITY DEFINER`) trigger. `--strict` fails on the readable target unless it is protected or allowlisted.
90
+ - **Side channels** that RLS leaves open: a `UNIQUE` or foreign-key error, or timing, can show that another tenant has a value. `verify` lists the keys on protected tables that leave out the tenant column (an information line; `--strict` fails on them): a unique key or primary key, an expression index included, and a foreign key from any table to a protected one. A single-column key on a generated value (an identity column, or a default from a sequence, `gen_random_uuid()` or `uuid_generate_v1`/`v4`) is not listed: a duplicate only shows that an id is taken. An exclusion constraint counts as a unique key, and its tenant column counts only with `=`. A foreign key passes only when the referencing table is protected and its own tenant column fills the referenced tenant column, so a plain `doc_id REFERENCES docs (id)` fails `--strict` (the app can link to, and probe for, another tenant's row). Put the tenant column in the key, as `UNIQUE (tenant_id, email)` and `FOREIGN KEY (tenant_id, doc_id) REFERENCES docs (tenant_id, id)`. Timing is not checked.
91
+ - **Your app setting `app.current_tenant` itself.** `verify` assumes only the middleware (`withTenant`) sets it, from the authenticated session.
92
+ - **What one role shares.** All tenants use one database role, so its sessions can read each other's current query text (`pg_stat_activity`: `migrate --harden` stops that, `--strict` fails on it), which can hold another tenant's literal values, and end each other's backends (`migrate --harden-signals` stops that). The role can also change its own defaults (`ALTER ROLE CURRENT_USER SET ...`) so its later connections fail; that is availability only. Information lines in `verify` report whether the role can signal other sessions or read their SQL, and which role-level defaults are set; they never fail.
93
+ - **Privileges granted later.** `verify` checks the database as it is when it runs. Run it in CI and after every change to grants.
94
+
95
+ Run it in CI and after schema changes, and review triggers, rules and jobs that copy tenant rows.
96
+
97
+ Exit codes: 0 all checks passed; 1 a check failed; 2 usage or connection error; 3 no failure, but a check was inconclusive (not a pass).
98
+
99
+ ## Tests
100
+
101
+ ```bash
102
+ cd packages/cli && npm ci
103
+ DBFW_PG_URL="postgres://postgres@localhost:5432/dbfw_test" npm test
104
+ ```
105
+
106
+ The tests run the CLI against a live PostgreSQL (CI: 14, 15, 16 and 17). `test/attacks.test.mjs` runs the attack corpus, `tests/attacks/postgresql.sql` in the repository: for each attack, `verify` must fail the named check, and its leak query, when it has one, must show data tenant A must not see; safe patterns must pass. The parser rejects an unknown or repeated directive, and the test pins the number of cases. Cases that need a newer server (`security_invoker` views need 15, `GRANT ... WITH INHERIT FALSE` needs 16) are skipped on older ones. They cover protected, unprotected, widened and altered tables, both policy modes, and each verify failure: a bypass role it is a member of, ownership of the table, of `public` or of a firewall function, column grants, `TRUNCATE`, a writable bypass list, definer views (also as a chain, or in another schema), materialized views, `SECURITY DEFINER` functions, other schemas, no tables, a tenant with no rows, partitions and an inconclusive probe.
@@ -0,0 +1,19 @@
1
+ export declare const CHECKS_FILE: URL;
2
+ export interface GlobalCheck {
3
+ name: string;
4
+ info: boolean;
5
+ sql: string;
6
+ }
7
+ export interface Checks {
8
+ /** The tables the migration skips. */
9
+ excluded: string[];
10
+ /** @check and @info blocks, in file order. */
11
+ checks: GlobalCheck[];
12
+ queries: Record<string, string>;
13
+ }
14
+ /** Parse the file: "-- @excluded", "-- @check", "-- @info" and "-- @query"
15
+ * lines start a block, which runs to the next "-- @" line. */
16
+ export declare function parseChecks(text: string): Checks;
17
+ export declare const CHECKS: () => Checks;
18
+ /** A named query from the file. */
19
+ export declare function q(name: string): string;
package/dist/checks.js ADDED
@@ -0,0 +1,55 @@
1
+ // The shared PostgreSQL checks (checks/postgresql.sql in the repository,
2
+ // shipped as sql/checks_postgresql.sql): the Python tools run the same file.
3
+ import { readFileSync } from "node:fs";
4
+ export const CHECKS_FILE = new URL("../sql/checks_postgresql.sql", import.meta.url);
5
+ /** Parse the file: "-- @excluded", "-- @check", "-- @info" and "-- @query"
6
+ * lines start a block, which runs to the next "-- @" line. */
7
+ export function parseChecks(text) {
8
+ const out = { excluded: [], checks: [], queries: {} };
9
+ let cur = null;
10
+ const flush = () => {
11
+ if (!cur)
12
+ return;
13
+ const sql = cur.lines.join("\n").trim();
14
+ if (!sql)
15
+ throw new Error(`checks: ${cur.name} has no SQL`);
16
+ if (cur.kind === "query") {
17
+ if (out.queries[cur.name])
18
+ throw new Error(`checks: a second @query ${cur.name}`);
19
+ out.queries[cur.name] = sql;
20
+ }
21
+ else {
22
+ if (out.checks.some((c) => c.name === cur.name))
23
+ throw new Error(`checks: a second check ${cur.name}`);
24
+ out.checks.push({ name: cur.name, info: cur.kind === "info", sql });
25
+ }
26
+ };
27
+ for (const line of text.replace(/\r\n/g, "\n").split("\n")) {
28
+ // Comment lines stay out of the SQL: a "$1" in one is no parameter.
29
+ if (!line.startsWith("-- @")) {
30
+ if (!line.trimStart().startsWith("--"))
31
+ cur?.lines.push(line);
32
+ continue;
33
+ }
34
+ const m = line.match(/^-- @(excluded|check|info|query) (\S.*)$/);
35
+ if (!m)
36
+ throw new Error(`checks: unknown line ${line}`);
37
+ flush();
38
+ cur = null;
39
+ if (m[1] === "excluded")
40
+ out.excluded.push(...m[2].trim().split(/\s+/));
41
+ else
42
+ cur = { kind: m[1], name: m[2].trim(), lines: [] };
43
+ }
44
+ flush();
45
+ return out;
46
+ }
47
+ let cached;
48
+ export const CHECKS = () => (cached ??= parseChecks(readFileSync(CHECKS_FILE, "utf8")));
49
+ /** A named query from the file. */
50
+ export function q(name) {
51
+ const sql = CHECKS().queries[name];
52
+ if (!sql)
53
+ throw new Error(`checks: no @query ${name}`);
54
+ return sql;
55
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ // db-firewall: migrate | scan | verify, for PostgreSQL.
3
+ import { parseArgs } from "node:util";
4
+ import pg from "pg";
5
+ import { TENANT_KEY_ENV, tenantKey } from "./key.js";
6
+ import { migrate } from "./migrate.js";
7
+ import { scan } from "./scan.js";
8
+ import { verify } from "./verify.js";
9
+ const USAGE = `Usage: db-firewall <command> [options]
10
+
11
+ Commands:
12
+ migrate Install the tenant RLS policies (run as an admin role)
13
+ scan Report which tables with the tenant column are protected
14
+ verify Connect as your app's role and test the isolation (needs --tenant
15
+ and DB_FIREWALL_TENANT_KEY)
16
+ key Print the database's tenant signing key (run as an admin role)
17
+
18
+ Options:
19
+ --url <url> PostgreSQL URL (default: $DATABASE_URL)
20
+ --column <name> Tenant column (default: tenant_id)
21
+ --schema <name> Schema (default: public; repeatable)
22
+ --mode <mode> migrate: permissive (default) or restrictive
23
+ --context <ctx> migrate/scan: signed (default) or plain. Signed: the app signs
24
+ the tenant per transaction with DB_FIREWALL_TENANT_KEY, so SQL
25
+ it runs cannot switch tenants. migrate installs that key when
26
+ set, else keeps the database's key or generates one.
27
+ --harden-signals migrate: revoke pg_terminate_backend / pg_cancel_backend from
28
+ PUBLIC in this database (needs a superuser), so tenants sharing
29
+ the app role cannot end each other's requests
30
+ --harden migrate: also revoke CREATE on the database and every schema,
31
+ TRIGGER and TRUNCATE on every table (default privileges
32
+ too), and pg_stat_activity, from PUBLIC and --app-role, by
33
+ any grantor (what verify fails on); needs a superuser.
34
+ Refuses if --app-role owns the database, a schema or a table
35
+ --app-role <name> migrate --harden: the app's role, revoked from as well
36
+ --ddl-guard migrate: install event triggers that protect a new table
37
+ with the tenant column at once, and refuse a change that
38
+ weakens a protected table (RLS off, a firewall policy
39
+ dropped or altered); needs a superuser
40
+ --tenant <id> verify: a tenant id that has rows in your data
41
+ --write-probe verify: also try a cross-tenant insert (rolled back, but it
42
+ fires BEFORE INSERT triggers and uses up a sequence value)
43
+ --exclude <glob> scan/verify: skip tables matching (repeatable; replaces the
44
+ defaults, which are the tables the migration skips)
45
+ --strict scan: exit 1 if any table is unprotected or none is found.
46
+ verify: also fail on anything the app role can read that is
47
+ not protected tenant data, and on SECURITY DEFINER functions
48
+ --allow-global <glob> verify --strict: schema.name of a table, view or
49
+ sequence, or schema.name(argtypes) of a function, that the
50
+ app may reach without tenant scoping (shared reference
51
+ data, say); repeatable. For array types, use * (app.f(*))
52
+ --allow-query-text verify --strict: accept that the role can read other
53
+ sessions' SQL (managed PostgreSQL, where only the provider's
54
+ superuser can revoke pg_stat_activity); send values as
55
+ parameters
56
+ --json machine-readable output
57
+ -h, --help
58
+
59
+ Exit codes: 0 ok; 1 a check failed (scan --strict: a table is unprotected or
60
+ none found); 2 usage or connection error; 3 verify: no failure, but a check was
61
+ inconclusive.
62
+ `;
63
+ async function main(argv) {
64
+ const { values, positionals } = parseArgs({
65
+ args: argv, allowPositionals: true,
66
+ options: {
67
+ url: { type: "string" }, column: { type: "string" }, schema: { type: "string", multiple: true },
68
+ "write-probe": { type: "boolean" }, "harden-signals": { type: "boolean" },
69
+ harden: { type: "boolean" }, "app-role": { type: "string" }, "ddl-guard": { type: "boolean" },
70
+ "allow-global": { type: "string", multiple: true }, "allow-query-text": { type: "boolean" },
71
+ mode: { type: "string" }, context: { type: "string" }, tenant: { type: "string" },
72
+ exclude: { type: "string", multiple: true },
73
+ strict: { type: "boolean" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" },
74
+ },
75
+ });
76
+ const command = positionals[0];
77
+ if (values.help || !command) {
78
+ process.stdout.write(USAGE);
79
+ return command || values.help ? 0 : 2;
80
+ }
81
+ const url = values.url ?? process.env.DATABASE_URL;
82
+ if (!url) {
83
+ process.stderr.write("db-firewall: --url or DATABASE_URL is required\n");
84
+ return 2;
85
+ }
86
+ const context = (values.context ?? "signed").toLowerCase();
87
+ if (context !== "signed" && context !== "plain") {
88
+ process.stderr.write(`db-firewall: --context must be signed or plain, got ${values.context}\n`);
89
+ return 2;
90
+ }
91
+ let key;
92
+ try {
93
+ key = tenantKey();
94
+ }
95
+ catch (err) {
96
+ process.stderr.write(`db-firewall: ${err.message}\n`);
97
+ return 2;
98
+ }
99
+ if (command === "verify" && !key) {
100
+ process.stderr.write(`db-firewall verify: set ${TENANT_KEY_ENV} to the key your app uses ` +
101
+ "(db-firewall key prints the database's key)\n");
102
+ return 2;
103
+ }
104
+ const common = { schemas: values.schema, column: values.column, exclude: values.exclude, context: context };
105
+ const client = new pg.Client({ connectionString: url });
106
+ await client.connect();
107
+ try {
108
+ if (command === "migrate") {
109
+ for (const schema of values.schema?.length ? values.schema : ["public"]) {
110
+ await migrate(client, { schema, column: values.column, mode: values.mode, context,
111
+ hardenSignals: values["harden-signals"], harden: values.harden,
112
+ appRole: values["app-role"], ddlGuard: values["ddl-guard"] });
113
+ }
114
+ const result = await scan(client, common);
115
+ print(values.json, { migrated: true, ...result }, () => `Migration applied. ` +
116
+ `${result.tables.filter((t) => t.protected).length} of ${result.tables.length} tables protected.\n` +
117
+ tablesText(result));
118
+ return 0;
119
+ }
120
+ if (command === "key") {
121
+ const { rows } = await client.query("SELECT encode(key, 'hex') AS key FROM db_firewall._firewall_keys");
122
+ if (!rows.length) {
123
+ process.stderr.write("db-firewall: no key: run migrate first\n");
124
+ return 1;
125
+ }
126
+ process.stdout.write(`${rows[0].key}\n`);
127
+ return 0;
128
+ }
129
+ if (command === "scan") {
130
+ const result = await scan(client, common);
131
+ print(values.json, result, () => tablesText(result));
132
+ return values.strict && (!result.tables.length || result.tables.some((t) => !t.protected)) ? 1 : 0;
133
+ }
134
+ if (command === "verify") {
135
+ if (!values.tenant) {
136
+ process.stderr.write("db-firewall verify: --tenant is required\n");
137
+ return 2;
138
+ }
139
+ if ((values["allow-global"]?.length || values["allow-query-text"]) && !values.strict) {
140
+ process.stderr.write("db-firewall verify: --allow-global and --allow-query-text need --strict\n");
141
+ return 2;
142
+ }
143
+ const result = await verify(client, { ...common, tenant: values.tenant, key: key,
144
+ writeProbe: values["write-probe"], strict: values.strict,
145
+ allowGlobal: values["allow-global"],
146
+ allowQueryText: values["allow-query-text"] });
147
+ const failed = result.checks.filter((c) => c.ok === false).length;
148
+ const unsure = result.checks.filter((c) => c.ok === null).length;
149
+ print(values.json, result, () => checksText(result.checks) + skippedText(result.excluded) +
150
+ (failed ? `\n${failed} check(s) FAILED${unsure ? `, ${unsure} inconclusive` : ""}.\n`
151
+ : unsure ? `\nNo failures, but ${unsure} check(s) inconclusive: not a pass.\n`
152
+ : "\nAll checks passed.\n"));
153
+ return failed ? 1 : unsure ? 3 : 0;
154
+ }
155
+ process.stderr.write(`db-firewall: unknown command ${command}\n\n${USAGE}`);
156
+ return 2;
157
+ }
158
+ finally {
159
+ await client.end();
160
+ }
161
+ }
162
+ function print(json, data, text) {
163
+ process.stdout.write(json ? JSON.stringify(data, null, 2) + "\n" : text());
164
+ }
165
+ function tablesText({ tables, excluded }) {
166
+ if (!tables.length)
167
+ return "FAIL no tables with the tenant column (wrong --column or --schema?)\n" +
168
+ skippedText(excluded);
169
+ return tables.map((t) => `${t.protected ? "OK " : "FAIL"} ${t.schema}.${t.table} (${t.policy_mode}, ` +
170
+ `${t.tenant_context ?? "no policies"}` +
171
+ (t.protected ? ")" : `; ${reasons(t)})`) +
172
+ (t.stale_base_policy ? " warning: stale base policy, re-run migrate" : "")).join("\n") + "\n" +
173
+ skippedText(excluded);
174
+ }
175
+ function skippedText(excluded) {
176
+ return excluded.length ? `Skipped (excluded): ${excluded.join(", ")}\n` : "";
177
+ }
178
+ function reasons(t) {
179
+ const r = [];
180
+ if (t.tenant_context === "plain")
181
+ r.push("plain tenant context, expected signed (re-run migrate, or scan --context plain)");
182
+ if (!t.rls_enabled)
183
+ r.push("RLS off");
184
+ else if (!t.rls_forced)
185
+ r.push("RLS not forced");
186
+ if (t.missing_policies.length)
187
+ r.push(`missing ${t.missing_policies.join(",")}`);
188
+ if (t.altered_policies.length && t.tenant_context !== "plain")
189
+ r.push(`altered ${t.altered_policies.join(",")}`);
190
+ if (t.policy_mode === "permissive" && t.extra_permissive_policies.length) {
191
+ r.push(`extra permissive ${t.extra_permissive_policies.join(",")}`);
192
+ }
193
+ if (t.predicate_check !== "ok")
194
+ r.push(t.predicate_check);
195
+ return r.join("; ");
196
+ }
197
+ function checksText(checks) {
198
+ return checks.map((c) => `${c.ok === true ? "OK " : c.ok === false ? "FAIL" : "-- "} ` +
199
+ `${c.table.padEnd(24)} ${c.check.padEnd(36)} ${c.detail}`).join("\n") + "\n";
200
+ }
201
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (err) => {
202
+ process.stderr.write(`db-firewall: ${err.message}\n`);
203
+ process.exitCode = 2;
204
+ });
package/dist/key.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare const TENANT_KEY_ENV = "DB_FIREWALL_TENANT_KEY";
2
+ /** The key (hex, 32 to 64 bytes), or undefined when unset. */
3
+ export declare function tenantKey(): Buffer | undefined;
4
+ /** hex(HMAC-SHA256(key, nonce:tenant)), as db_firewall._firewall_sign() computes it. */
5
+ export declare const sign: (key: Buffer, nonce: string, tenant: string) => string;
package/dist/key.js ADDED
@@ -0,0 +1,15 @@
1
+ // The signing key the app uses (DB_FIREWALL_TENANT_KEY), shared by migrate and verify.
2
+ import { createHmac } from "node:crypto";
3
+ export const TENANT_KEY_ENV = "DB_FIREWALL_TENANT_KEY";
4
+ /** The key (hex, 32 to 64 bytes), or undefined when unset. */
5
+ export function tenantKey() {
6
+ const raw = (process.env[TENANT_KEY_ENV] ?? "").trim();
7
+ if (!raw)
8
+ return undefined;
9
+ if (!/^(?:[0-9a-fA-F]{2}){32,64}$/.test(raw)) {
10
+ throw new Error(`${TENANT_KEY_ENV} must be 32 to 64 bytes, hex-encoded`);
11
+ }
12
+ return Buffer.from(raw, "hex");
13
+ }
14
+ /** hex(HMAC-SHA256(key, nonce:tenant)), as db_firewall._firewall_sign() computes it. */
15
+ export const sign = (key, nonce, tenant) => createHmac("sha256", key).update(`${nonce}:${tenant}`, "utf8").digest("hex");
@@ -0,0 +1,12 @@
1
+ import type pg from "pg";
2
+ export declare const MIGRATION: URL;
3
+ export declare function migrate(client: pg.ClientBase, opts?: {
4
+ schema?: string;
5
+ column?: string;
6
+ mode?: string;
7
+ context?: string;
8
+ hardenSignals?: boolean;
9
+ harden?: boolean;
10
+ appRole?: string;
11
+ ddlGuard?: boolean;
12
+ }): Promise<void>;
@@ -0,0 +1,43 @@
1
+ // migrate: run the bundled migrations/001_postgresql.sql with the settings it reads.
2
+ import { readFileSync } from "node:fs";
3
+ import { tenantKey } from "./key.js";
4
+ export const MIGRATION = new URL("../sql/001_postgresql.sql", import.meta.url);
5
+ export async function migrate(client, opts = {}) {
6
+ const mode = (opts.mode ?? "permissive").toLowerCase();
7
+ if (mode !== "permissive" && mode !== "restrictive") {
8
+ throw new Error(`--mode must be permissive or restrictive, got ${opts.mode}`);
9
+ }
10
+ const context = (opts.context ?? "signed").toLowerCase();
11
+ if (context !== "signed" && context !== "plain") {
12
+ throw new Error(`--context must be signed or plain, got ${opts.context}`);
13
+ }
14
+ // DB_FIREWALL_TENANT_KEY installs (or rotates to) that key; unset keeps the
15
+ // database's key, or generates one on the first run. The script clears it.
16
+ const key = tenantKey();
17
+ // Session settings, as run_migration() does: the script runs its own
18
+ // BEGIN ... COMMIT, and this connection ends after the command.
19
+ // Keep the key out of the server log where this role may (a superuser):
20
+ // statement and parameter logging off for this session.
21
+ if (key) {
22
+ await client.query("SELECT set_config('log_statement', 'none', false), " +
23
+ "set_config('log_min_duration_statement', '-1', false), set_config('log_parameter_max_length', '0', false), " +
24
+ "set_config('log_parameter_max_length_on_error', '0', false) FROM pg_roles WHERE rolname = current_user AND rolsuper");
25
+ }
26
+ await client.query("SELECT set_config('dbfw.tenant_column', $1, false), set_config('dbfw.schema', $2, false), " +
27
+ "set_config('dbfw.policy_mode', $3, false), set_config('dbfw.context', $4, false), " +
28
+ "set_config('dbfw.tenant_key', $5, false), set_config('dbfw.harden_signals', $6, false), " +
29
+ "set_config('dbfw.harden', $7, false), set_config('dbfw.app_role', $8, false), " +
30
+ "set_config('dbfw.ddl_guard', $9, false)", [opts.column ?? "tenant_id", opts.schema ?? "public", mode, context, key?.toString("hex") ?? "",
31
+ opts.hardenSignals ? "on" : "off", opts.harden ? "on" : "off", opts.appRole ?? "", opts.ddlGuard ? "on" : "off"]);
32
+ try {
33
+ await client.query(readFileSync(MIGRATION, "utf8"));
34
+ }
35
+ catch (err) {
36
+ await client.query("ROLLBACK").catch(() => { }); // the script's transaction, if left open
37
+ throw err;
38
+ }
39
+ finally {
40
+ // The script clears the key when it succeeds; a failed run must not leave it.
41
+ await client.query("SELECT set_config('dbfw.tenant_key', '', false)").catch(() => { });
42
+ }
43
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type pg from "pg";
2
+ /** Exactly the tables migrations/001_postgresql.sql skips (@excluded in the
3
+ * checks file), so a table the migration protects is never skipped here.
4
+ * Skipped tables are reported. */
5
+ export declare const DEFAULT_EXCLUDED: string[];
6
+ export interface TableReport {
7
+ schema: string;
8
+ table: string;
9
+ protected: boolean;
10
+ has_tenant_col: true;
11
+ has_rls_policy: boolean;
12
+ owner: string;
13
+ rls_enabled: boolean;
14
+ rls_forced: boolean;
15
+ policy_mode: "permissive" | "restrictive";
16
+ missing_policies: string[];
17
+ extra_permissive_policies: string[];
18
+ altered_policies: string[];
19
+ stale_base_policy: boolean;
20
+ predicate_check: string;
21
+ /** The context the policies use: signed, plain, or altered (neither); null without policies. */
22
+ tenant_context: "signed" | "plain" | "altered" | null;
23
+ }
24
+ export type Context = "signed" | "plain";
25
+ /** The migration's predicate for dbfw.context signed (default) or plain. */
26
+ export declare function predicate(column: string, context: Context): string;
27
+ export declare const ident: (name: string) => string;
28
+ /** fnmatch-style glob (*, ?, [abc], [!abc]), as the Python scanner matches. */
29
+ export declare function globMatch(pattern: string, name: string): boolean;
30
+ export interface ScanResult {
31
+ tables: TableReport[];
32
+ excluded: string[];
33
+ }
34
+ /**
35
+ * Every function the migration creates must match it (body, SECURITY DEFINER,
36
+ * settings, volatility, language, return type), with no extra db_firewall._firewall_*
37
+ * function: the policies call some of them, others sign, and a changed body
38
+ * (say, a session-setting "maintenance switch") opens every table while the
39
+ * policies still look exact. Every db_firewall._firewall_* object must have one
40
+ * owner, or only superusers: an object another role planted keeps its owner.
41
+ */
42
+ export declare function firewallObjects(client: pg.ClientBase): Promise<{
43
+ ok: boolean;
44
+ detail: string;
45
+ }>;
46
+ /** Run fn with search_path = pg_catalog, pg_temp, so a planted operator in
47
+ * another schema cannot hide in how a policy reads, then put it back. */
48
+ export declare function withCatalogPath<T>(client: pg.ClientBase, fn: () => Promise<T>): Promise<T>;
49
+ /** Scan every table in the schemas that has the tenant column. */
50
+ export declare function scan(client: pg.ClientBase, opts?: {
51
+ schemas?: string[];
52
+ column?: string;
53
+ exclude?: string[];
54
+ context?: Context;
55
+ }): Promise<ScanResult>;