@everystack/cli 0.2.37 → 0.3.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 +681 -0
- package/README.md +33 -6
- package/package.json +28 -9
- package/src/cli/authz-compile.ts +212 -0
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-owner-probe.ts +200 -0
- package/src/cli/authz-reconcile.ts +127 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/aws.ts +100 -0
- package/src/cli/backup.ts +99 -0
- package/src/cli/commands/db-authz.ts +292 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +133 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/db.ts +215 -5
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/commands/logs.ts +67 -33
- package/src/cli/commands/security.ts +185 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +85 -2
- package/src/cli/migration-compile.ts +69 -0
- package/src/cli/migration-generate.ts +175 -0
- package/src/cli/model-api.ts +35 -0
- package/src/cli/model-render.ts +262 -0
- package/src/cli/pg-ident.ts +59 -0
- package/src/cli/rds-snapshot.ts +47 -0
- package/src/cli/schema-compile.ts +439 -0
- package/src/cli/schema-diff.ts +605 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/security-audit.ts +405 -0
- package/src/cli/security-catalog.ts +170 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner-aware red-team — proving RLS actually isolates one user from another.
|
|
3
|
+
*
|
|
4
|
+
* `authz-redteam.ts` probes (role × table × command): it `SET ROLE`s and asks "does the
|
|
5
|
+
* role hold the privilege?", treating an RLS-filtered row as "privilege present" — it is
|
|
6
|
+
* deliberately identity-blind. That leaves the highest-value class unverified: can user A
|
|
7
|
+
* read or mutate user B's row? RLS owner policies turn on the JWT identity
|
|
8
|
+
* (`request.jwt.claims ->> 'sub'`), not the role, so two `authenticated` users look
|
|
9
|
+
* identical to the grant probe. This module fills that gap.
|
|
10
|
+
*
|
|
11
|
+
* For each owner-scoped table it DISCOVERS two distinct owner values from the live data
|
|
12
|
+
* (as the privileged connection, before assuming a role), then assumes role `authenticated`
|
|
13
|
+
* and sets `request.jwt.claims` to the first identity and checks three things:
|
|
14
|
+
* - read-own — the owner's own rows ARE visible (the anti-vacuous twin: a deny that
|
|
15
|
+
* also hides the owner's rows is a policy bug, not a pass).
|
|
16
|
+
* - read-other — the other identity's rows are NOT visible (an IDOR read leak).
|
|
17
|
+
* - write-other— an UPDATE touching the other identity's rows affects ZERO rows (an IDOR
|
|
18
|
+
* write leak). Rolled back by the runner; nothing persists.
|
|
19
|
+
* A table with fewer than two distinct owners records `unprobed` (can't test isolation with
|
|
20
|
+
* one identity) — surfaced, never a false pass. Discovery casts the owner to text, so the
|
|
21
|
+
* same probe works for a uuid, bigint, or text owner column.
|
|
22
|
+
*
|
|
23
|
+
* The builder and evaluator are pure; the SQL runs through the `db:authz:probe` action, which
|
|
24
|
+
* executes it in a transaction it always rolls back.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** One owner-scoped table to probe. Identity values are discovered from the data at run time. */
|
|
28
|
+
export interface OwnerProbe {
|
|
29
|
+
/** Schema-qualified table, e.g. `public.notes`. */
|
|
30
|
+
table: string;
|
|
31
|
+
/** The SQL column holding the owner id (e.g. `author_id`). */
|
|
32
|
+
ownerColumn: string;
|
|
33
|
+
/** JWT claim carrying the owner id. Default `sub`. */
|
|
34
|
+
claim?: string;
|
|
35
|
+
/** App role to assume. Default `authenticated`. */
|
|
36
|
+
role?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type OwnerCheck = 'discover' | 'read-own' | 'read-other' | 'write-other';
|
|
40
|
+
/** `visible`/`hidden` = rows seen-or-affected / none; `error` = the attempt raised; `unprobed` = <2 owners. */
|
|
41
|
+
export type OwnerOutcome = 'visible' | 'hidden' | 'error' | 'unprobed';
|
|
42
|
+
|
|
43
|
+
export interface OwnerProbeResult {
|
|
44
|
+
table: string;
|
|
45
|
+
check: OwnerCheck;
|
|
46
|
+
outcome: OwnerOutcome;
|
|
47
|
+
/** Rows seen (read) / affected (write); for `discover`, the number of distinct owners found. */
|
|
48
|
+
rows: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface OwnerFinding {
|
|
52
|
+
severity: 'read-leak' | 'write-leak' | 'vacuous' | 'unprobed';
|
|
53
|
+
table: string;
|
|
54
|
+
detail: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
58
|
+
|
|
59
|
+
/** A SQL single-quoted literal, doubling embedded quotes. */
|
|
60
|
+
function sqlLiteral(value: string): string {
|
|
61
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the server-side owner probe. Returns SQL that populates
|
|
66
|
+
* `_authz_owner_probe(tbl, check, outcome, rows)` for every probe, then a trailing SELECT.
|
|
67
|
+
* The caller (the `db:authz:probe` action) runs both in one transaction it always rolls back.
|
|
68
|
+
* Schema/table/column identifiers are split and `%I`-quoted in PL/pgSQL; discovered identity
|
|
69
|
+
* values live in PL/pgSQL variables (never concatenated into SQL), so there is no injection
|
|
70
|
+
* surface from the data. Only the column/claim/role idents are inlined, and they are validated.
|
|
71
|
+
*/
|
|
72
|
+
export function buildOwnerProbeSql(probes: OwnerProbe[]): string {
|
|
73
|
+
const blocks = probes.map((p) => {
|
|
74
|
+
const role = p.role ?? 'authenticated';
|
|
75
|
+
const claim = p.claim ?? 'sub';
|
|
76
|
+
if (!IDENT.test(role)) throw new Error(`Invalid role: ${JSON.stringify(role)}`);
|
|
77
|
+
if (!IDENT.test(p.ownerColumn)) throw new Error(`Invalid owner column: ${JSON.stringify(p.ownerColumn)}`);
|
|
78
|
+
if (!IDENT.test(claim)) throw new Error(`Invalid claim: ${JSON.stringify(claim)}`);
|
|
79
|
+
|
|
80
|
+
const tbl = sqlLiteral(p.table);
|
|
81
|
+
const col = sqlLiteral(p.ownerColumn);
|
|
82
|
+
const roleLit = sqlLiteral(role);
|
|
83
|
+
const claimLit = sqlLiteral(claim);
|
|
84
|
+
|
|
85
|
+
return `
|
|
86
|
+
-- ${p.table} (owner ${p.ownerColumn})
|
|
87
|
+
v_schema := split_part(${tbl}, '.', 1);
|
|
88
|
+
v_table := split_part(${tbl}, '.', 2);
|
|
89
|
+
v_write_err := false;
|
|
90
|
+
EXECUTE format(
|
|
91
|
+
'SELECT array_agg(v) FROM (SELECT DISTINCT %I::text AS v FROM %I.%I WHERE %I IS NOT NULL ORDER BY v LIMIT 2) s',
|
|
92
|
+
${col}, v_schema, v_table, ${col}
|
|
93
|
+
) INTO v_owners;
|
|
94
|
+
|
|
95
|
+
IF v_owners IS NULL OR array_length(v_owners, 1) < 2 THEN
|
|
96
|
+
INSERT INTO _authz_owner_probe VALUES (${tbl}, 'discover', 'unprobed', COALESCE(array_length(v_owners, 1), 0));
|
|
97
|
+
ELSE
|
|
98
|
+
v_owner := v_owners[1];
|
|
99
|
+
v_intruder := v_owners[2];
|
|
100
|
+
|
|
101
|
+
EXECUTE format('SET LOCAL ROLE %I', ${roleLit});
|
|
102
|
+
PERFORM set_config('request.jwt.claims', jsonb_build_object(${claimLit}, v_owner, 'role', ${roleLit})::text, true);
|
|
103
|
+
|
|
104
|
+
EXECUTE format('SELECT count(*) FROM %I.%I WHERE %I = %L', v_schema, v_table, ${col}, v_owner) INTO v_own;
|
|
105
|
+
EXECUTE format('SELECT count(*) FROM %I.%I WHERE %I = %L', v_schema, v_table, ${col}, v_intruder) INTO v_other;
|
|
106
|
+
BEGIN
|
|
107
|
+
EXECUTE format('UPDATE %I.%I SET %I = %I WHERE %I = %L', v_schema, v_table, ${col}, ${col}, ${col}, v_intruder);
|
|
108
|
+
GET DIAGNOSTICS v_write = ROW_COUNT;
|
|
109
|
+
EXCEPTION WHEN OTHERS THEN
|
|
110
|
+
v_write := 0; v_write_err := true;
|
|
111
|
+
END;
|
|
112
|
+
|
|
113
|
+
RESET ROLE;
|
|
114
|
+
PERFORM set_config('request.jwt.claims', '', true);
|
|
115
|
+
|
|
116
|
+
INSERT INTO _authz_owner_probe VALUES
|
|
117
|
+
(${tbl}, 'read-own', CASE WHEN v_own > 0 THEN 'visible' ELSE 'hidden' END, v_own),
|
|
118
|
+
(${tbl}, 'read-other', CASE WHEN v_other > 0 THEN 'visible' ELSE 'hidden' END, v_other),
|
|
119
|
+
(${tbl}, 'write-other', CASE WHEN v_write_err THEN 'error' WHEN v_write > 0 THEN 'visible' ELSE 'hidden' END, v_write);
|
|
120
|
+
END IF;`;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return `
|
|
124
|
+
DROP TABLE IF EXISTS _authz_owner_probe;
|
|
125
|
+
CREATE TEMP TABLE _authz_owner_probe(tbl text, "check" text, outcome text, rows int) ON COMMIT DROP;
|
|
126
|
+
DO $owner$
|
|
127
|
+
DECLARE
|
|
128
|
+
v_schema text;
|
|
129
|
+
v_table text;
|
|
130
|
+
v_owners text[];
|
|
131
|
+
v_owner text;
|
|
132
|
+
v_intruder text;
|
|
133
|
+
v_own bigint;
|
|
134
|
+
v_other bigint;
|
|
135
|
+
v_write bigint;
|
|
136
|
+
v_write_err boolean;
|
|
137
|
+
BEGIN
|
|
138
|
+
${blocks.join('\n')}
|
|
139
|
+
END
|
|
140
|
+
$owner$;
|
|
141
|
+
`.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read the probe outcomes (same transaction as buildOwnerProbeSql; the runner rolls back). */
|
|
145
|
+
export const OWNER_PROBE_SELECT_SQL =
|
|
146
|
+
'SELECT tbl AS table, "check", outcome, rows FROM _authz_owner_probe ORDER BY tbl, "check"';
|
|
147
|
+
|
|
148
|
+
/** Map a probe-result row to a typed OwnerProbeResult. */
|
|
149
|
+
export function toOwnerProbeResult(row: any): OwnerProbeResult {
|
|
150
|
+
return {
|
|
151
|
+
table: String(row.table),
|
|
152
|
+
check: String(row.check) as OwnerCheck,
|
|
153
|
+
outcome: String(row.outcome) as OwnerOutcome,
|
|
154
|
+
rows: Number(row.rows ?? 0),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Turn probe outcomes into findings. Each is a behavioural authorization failure:
|
|
160
|
+
* - read-leak — as the owner, the other identity's rows were visible (read-other = visible).
|
|
161
|
+
* - write-leak — as the owner, an UPDATE touched the other identity's rows (write-other visible).
|
|
162
|
+
* - vacuous — the owner could NOT see their own rows (read-own = hidden): the policy denies
|
|
163
|
+
* the owner too, so the read-other deny proves nothing — the faithfulness guard.
|
|
164
|
+
* - unprobed — fewer than two distinct owners in the table; isolation can't be tested (a
|
|
165
|
+
* warning, not a verdict — seed a second owner's row, or accept the gap).
|
|
166
|
+
* A correctly-isolated table with two owners produces zero findings.
|
|
167
|
+
*/
|
|
168
|
+
export function evaluateOwnerProbe(results: OwnerProbeResult[]): OwnerFinding[] {
|
|
169
|
+
const findings: OwnerFinding[] = [];
|
|
170
|
+
const byTable = new Map<string, Map<OwnerCheck, OwnerProbeResult>>();
|
|
171
|
+
for (const r of results) {
|
|
172
|
+
(byTable.get(r.table) ?? byTable.set(r.table, new Map()).get(r.table)!).set(r.check, r);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const [table, checks] of byTable) {
|
|
176
|
+
if (checks.get('discover')?.outcome === 'unprobed') {
|
|
177
|
+
findings.push({ severity: 'unprobed', table,
|
|
178
|
+
detail: `fewer than two distinct owners in ${table} — cannot test reader/writer isolation; seed a second owner's row to cover it` });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const own = checks.get('read-own');
|
|
182
|
+
const other = checks.get('read-other');
|
|
183
|
+
const write = checks.get('write-other');
|
|
184
|
+
|
|
185
|
+
if (own && own.outcome !== 'visible') {
|
|
186
|
+
findings.push({ severity: 'vacuous', table,
|
|
187
|
+
detail: `owner could not see their own rows in ${table} — the policy denies the owner too, or the probe identity is wrong; the isolation checks prove nothing until this is fixed` });
|
|
188
|
+
continue; // the other checks are meaningless while the owner can't see own rows
|
|
189
|
+
}
|
|
190
|
+
if (other && other.outcome === 'visible') {
|
|
191
|
+
findings.push({ severity: 'read-leak', table,
|
|
192
|
+
detail: `as the owner, ${other.rows} of another user's row(s) in ${table} were visible — RLS does not isolate readers (IDOR read)` });
|
|
193
|
+
}
|
|
194
|
+
if (write && write.outcome === 'visible') {
|
|
195
|
+
findings.push({ severity: 'write-leak', table,
|
|
196
|
+
detail: `as the owner, an UPDATE affected ${write.rows} of another user's row(s) in ${table} — RLS does not isolate writers (IDOR write)` });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return findings;
|
|
200
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile — the authz diff turned back into SQL (the "correct" to the contract
|
|
3
|
+
* suite's "validate"). `diffContracts` detects drift as prose findings;
|
|
4
|
+
* `emitReconcileSql` emits the idempotent DDL that makes the live database match
|
|
5
|
+
* the declared contract.
|
|
6
|
+
*
|
|
7
|
+
* It works off the structured contracts, not DriftFinding (whose `detail` is
|
|
8
|
+
* prose — "USING changed" can't be turned into SQL). Declared is the target
|
|
9
|
+
* (compiled from the Model); live is the current database; the output transforms
|
|
10
|
+
* live -> declared.
|
|
11
|
+
*
|
|
12
|
+
* Authz-only by construction: it emits CREATE/DROP POLICY, GRANT/REVOKE, and
|
|
13
|
+
* ALTER TABLE ... ROW LEVEL SECURITY — never anything that touches table data. A
|
|
14
|
+
* policy/grant change loses no rows and is idempotently replaceable, which is why
|
|
15
|
+
* this layer reconciles without a migration file (unlike the data layer).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
|
|
19
|
+
import { quoteQualified } from './pg-ident.js';
|
|
20
|
+
|
|
21
|
+
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
22
|
+
function isWrapped(s: string): boolean {
|
|
23
|
+
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
24
|
+
let depth = 0;
|
|
25
|
+
for (let i = 0; i < s.length; i++) {
|
|
26
|
+
if (s[i] === '(') depth++;
|
|
27
|
+
else if (s[i] === ')') {
|
|
28
|
+
depth--;
|
|
29
|
+
if (depth === 0 && i < s.length - 1) return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return depth === 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A USING/WITH CHECK clause expression, parenthesized exactly once. */
|
|
36
|
+
function clause(expr: string): string {
|
|
37
|
+
return isWrapped(expr) ? expr : `(${expr})`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A CREATE POLICY statement from a policy descriptor. */
|
|
41
|
+
function policyCreateSql(table: string, p: PolicyContract): string {
|
|
42
|
+
const parts = [`CREATE POLICY ${p.name} ON ${table}`];
|
|
43
|
+
if (!p.permissive) parts.push('AS RESTRICTIVE');
|
|
44
|
+
parts.push(`FOR ${p.command}`);
|
|
45
|
+
parts.push(`TO ${p.roles.join(', ')}`);
|
|
46
|
+
if (p.using != null) parts.push(`USING ${clause(p.using)}`);
|
|
47
|
+
if (p.check != null) parts.push(`WITH CHECK ${clause(p.check)}`);
|
|
48
|
+
return `${parts.join(' ')};`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function policyDropSql(table: string, name: string): string {
|
|
52
|
+
return `DROP POLICY IF EXISTS ${name} ON ${table};`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Two policies are the same authorization when every diffed field matches. */
|
|
56
|
+
function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
|
|
57
|
+
return a.command === b.command
|
|
58
|
+
&& a.permissive === b.permissive
|
|
59
|
+
&& a.roles.join(',') === b.roles.join(',')
|
|
60
|
+
&& (a.using ?? '') === (b.using ?? '')
|
|
61
|
+
&& (a.check ?? '') === (b.check ?? '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
65
|
+
return new Map(c.tables.map((t) => [t.table, t]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Emit the ordered SQL that transforms the live database into the declared
|
|
70
|
+
* contract. Empty when they already match (the reconcile no-op). Order per table:
|
|
71
|
+
* enable RLS, drop removed/changed policies, create added/changed policies, then
|
|
72
|
+
* revoke/grant — so a replaced policy never briefly co-exists with its old form
|
|
73
|
+
* and policies exist before grants reference the table.
|
|
74
|
+
*/
|
|
75
|
+
export function emitReconcileSql(declared: AuthzContract, live: AuthzContract): string[] {
|
|
76
|
+
const sql: string[] = [];
|
|
77
|
+
const lTables = tableMap(live);
|
|
78
|
+
|
|
79
|
+
for (const d of declared.tables) {
|
|
80
|
+
const l = lTables.get(d.table);
|
|
81
|
+
// The contract keys on the bare `<schema>.<table>` (so the diff matches both producers);
|
|
82
|
+
// the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
|
|
83
|
+
const table = quoteQualified(d.table);
|
|
84
|
+
reconcileRls(table, d, l, sql);
|
|
85
|
+
reconcilePolicies(table, d, l, sql);
|
|
86
|
+
reconcileGrants(table, d, l, sql);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return sql;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function reconcileRls(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
93
|
+
if (d.rls.enabled && !l?.rls.enabled) out.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
|
|
94
|
+
if (d.rls.forced && !l?.rls.forced) out.push(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;`);
|
|
95
|
+
if (!d.rls.enabled && l?.rls.enabled) out.push(`ALTER TABLE ${table} DISABLE ROW LEVEL SECURITY;`);
|
|
96
|
+
if (!d.rls.forced && l?.rls.forced) out.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
100
|
+
const dMap = new Map(d.policies.map((p) => [p.name, p]));
|
|
101
|
+
const lMap = new Map((l?.policies ?? []).map((p) => [p.name, p]));
|
|
102
|
+
|
|
103
|
+
// Drops first: policies removed, or changed (dropped then recreated below).
|
|
104
|
+
for (const [name, lp] of lMap) {
|
|
105
|
+
const dp = dMap.get(name);
|
|
106
|
+
if (!dp || !policiesEqual(dp, lp)) out.push(policyDropSql(table, name));
|
|
107
|
+
}
|
|
108
|
+
// Creates: policies added, or changed (recreated to the declared form).
|
|
109
|
+
for (const [name, dp] of dMap) {
|
|
110
|
+
const lp = lMap.get(name);
|
|
111
|
+
if (!lp || !policiesEqual(dp, lp)) out.push(policyCreateSql(table, dp));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
116
|
+
const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l?.grants ?? {})]);
|
|
117
|
+
for (const grantee of [...grantees].sort()) {
|
|
118
|
+
const declared = new Set(d.grants[grantee] ?? []);
|
|
119
|
+
const live = new Set(l?.grants?.[grantee] ?? []);
|
|
120
|
+
const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
|
|
121
|
+
|
|
122
|
+
const toRevoke = [...live].filter((p) => !declared.has(p)).sort();
|
|
123
|
+
const toGrant = [...declared].filter((p) => !live.has(p)).sort();
|
|
124
|
+
if (toRevoke.length) out.push(`REVOKE ${toRevoke.join(', ')} ON ${table} FROM ${target};`);
|
|
125
|
+
if (toGrant.length) out.push(`GRANT ${toGrant.join(', ')} ON ${table} TO ${target};`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Authorization Contract red-team — proving *enforcement*, not just declaration.
|
|
3
|
+
*
|
|
4
|
+
* db:authz:diff proves the live ACL matches the committed contract (static text). This
|
|
5
|
+
* proves the database actually *behaves* that way: for each (role, table, command) it
|
|
6
|
+
* SET ROLEs into the app role and attempts the operation, then asks "was it allowed?".
|
|
7
|
+
* That catches enforcement holes static inspection cannot — privilege escalation via
|
|
8
|
+
* role inheritance, a BYPASSRLS attribute, or a `USING(true)` policy quietly defeating
|
|
9
|
+
* intent. The worst finding is a role that can do *more* than the contract declares.
|
|
10
|
+
*
|
|
11
|
+
* Execution model (built into the `db:authz:probe` ops action): one server-side DO
|
|
12
|
+
* block loops every (role, table, command); each probe runs inside its own savepoint
|
|
13
|
+
* (SET LOCAL ROLE -> attempt -> classify SQLSTATE -> rollback to savepoint -> RESET
|
|
14
|
+
* ROLE) and writes its outcome to a temp table the surrounding transaction then
|
|
15
|
+
* discards. Nothing persists; one round trip. The builder and evaluator here are pure.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { AuthzContract, TableContract } from './authz-contract.js';
|
|
19
|
+
|
|
20
|
+
export type ProbeCommand = 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
|
|
21
|
+
export const PROBE_COMMANDS: ProbeCommand[] = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
|
|
22
|
+
|
|
23
|
+
/** SELECT/INSERT/UPDATE/DELETE map to the privilege a grant must carry to allow them. */
|
|
24
|
+
const COMMAND_PRIVILEGE: Record<ProbeCommand, string> = {
|
|
25
|
+
SELECT: 'SELECT', INSERT: 'INSERT', UPDATE: 'UPDATE', DELETE: 'DELETE',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ProbeOutcome =
|
|
29
|
+
| 'allowed' // the privilege is present (RLS may still filter rows)
|
|
30
|
+
| 'denied-privilege' // no grant: "permission denied for table"
|
|
31
|
+
| 'denied-rls' // grant present, RLS blocked the row: privilege IS present
|
|
32
|
+
| 'no-impersonate'; // the probing role could not SET ROLE into this role
|
|
33
|
+
|
|
34
|
+
export interface ProbeResult {
|
|
35
|
+
role: string;
|
|
36
|
+
table: string; // schema-qualified
|
|
37
|
+
command: ProbeCommand;
|
|
38
|
+
outcome: ProbeOutcome;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RedTeamFinding {
|
|
42
|
+
severity: 'hole' | 'broken' | 'unprobed';
|
|
43
|
+
role: string;
|
|
44
|
+
table: string;
|
|
45
|
+
command: ProbeCommand;
|
|
46
|
+
detail: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Does a role hold a privilege on a table — by table grant, by a column-scoped grant
|
|
51
|
+
* (for SELECT/INSERT/UPDATE), or via a PUBLIC grant (which applies to every role)? The
|
|
52
|
+
* privilege-isolated probe (`SELECT 1`, `UPDATE ... WHERE false`) needs the privilege on
|
|
53
|
+
* *any* column, so a column grant confers it — matching what the database enforces, and
|
|
54
|
+
* the reason a table-grant-only check produced false "holes" on `auth.users`.
|
|
55
|
+
*/
|
|
56
|
+
export function hasPrivilege(table: TableContract, role: string, privilege: string): boolean {
|
|
57
|
+
const tableGrant = (g: string) => (table.grants[g] ?? []).includes(privilege);
|
|
58
|
+
const columnGrant = (g: string) => (table.columnGrants?.[g]?.[privilege]?.length ?? 0) > 0;
|
|
59
|
+
return tableGrant(role) || columnGrant(role) || tableGrant('PUBLIC') || columnGrant('PUBLIC');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Roles to probe: every grantee named in the contract (minus PUBLIC) plus anon — so a
|
|
63
|
+
* role that should have NO access is still actively tested for default-deny. */
|
|
64
|
+
export function probeRoles(contract: AuthzContract): string[] {
|
|
65
|
+
const roles = new Set<string>(['anon']);
|
|
66
|
+
const add = (g: string) => { if (g.toUpperCase() !== 'PUBLIC') roles.add(g); };
|
|
67
|
+
for (const t of contract.tables) {
|
|
68
|
+
Object.keys(t.grants).forEach(add);
|
|
69
|
+
Object.keys(t.columnGrants ?? {}).forEach(add);
|
|
70
|
+
}
|
|
71
|
+
return [...roles].sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A Postgres string-array literal: `ARRAY['a','b']` with each element escaped. */
|
|
75
|
+
function pgArrayLiteral(values: string[]): string {
|
|
76
|
+
const escaped = values.map((v) => `'${v.replace(/'/g, "''")}'`);
|
|
77
|
+
return `ARRAY[${escaped.join(', ')}]::text[]`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the server-side probe. Returns SQL that populates `_authz_probe(role, tbl,
|
|
82
|
+
* command, outcome)` and a trailing SELECT of it. The caller runs both inside one
|
|
83
|
+
* transaction it always rolls back. Probe statements are privilege-isolated:
|
|
84
|
+
* SELECT 1 FROM t WHERE false — SELECT privilege, reads no rows
|
|
85
|
+
* INSERT INTO t DEFAULT VALUES — INSERT privilege (rolled back; NOT NULL = allowed)
|
|
86
|
+
* UPDATE t SET col = col WHERE false — UPDATE privilege, touches no rows
|
|
87
|
+
* DELETE FROM t WHERE false — DELETE privilege, touches no rows
|
|
88
|
+
* "permission denied" -> denied-privilege; "row-level security" -> denied-rls; any
|
|
89
|
+
* other error (constraint, etc.) means the privilege passed -> allowed.
|
|
90
|
+
*/
|
|
91
|
+
export function buildProbeSql(roles: string[], tables: string[]): string {
|
|
92
|
+
return `
|
|
93
|
+
DROP TABLE IF EXISTS _authz_probe;
|
|
94
|
+
CREATE TEMP TABLE _authz_probe(role text, tbl text, command text, outcome text) ON COMMIT DROP;
|
|
95
|
+
DO $probe$
|
|
96
|
+
DECLARE
|
|
97
|
+
v_role text;
|
|
98
|
+
v_tbl text;
|
|
99
|
+
v_cmd text;
|
|
100
|
+
v_col text;
|
|
101
|
+
v_stmt text;
|
|
102
|
+
v_outcome text;
|
|
103
|
+
BEGIN
|
|
104
|
+
FOREACH v_role IN ARRAY ${pgArrayLiteral(roles)} LOOP
|
|
105
|
+
FOREACH v_tbl IN ARRAY ${pgArrayLiteral(tables)} LOOP
|
|
106
|
+
FOREACH v_cmd IN ARRAY ${pgArrayLiteral(PROBE_COMMANDS)} LOOP
|
|
107
|
+
v_stmt := CASE v_cmd
|
|
108
|
+
WHEN 'SELECT' THEN format('SELECT 1 FROM %I.%I WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))
|
|
109
|
+
WHEN 'INSERT' THEN format('INSERT INTO %I.%I DEFAULT VALUES', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))
|
|
110
|
+
WHEN 'DELETE' THEN format('DELETE FROM %I.%I WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))
|
|
111
|
+
WHEN 'UPDATE' THEN NULL
|
|
112
|
+
END;
|
|
113
|
+
IF v_cmd = 'UPDATE' THEN
|
|
114
|
+
SELECT quote_ident(attname) INTO v_col FROM pg_attribute
|
|
115
|
+
WHERE attrelid = format('%I.%I', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2))::regclass
|
|
116
|
+
AND attnum > 0 AND NOT attisdropped ORDER BY attnum LIMIT 1;
|
|
117
|
+
v_stmt := format('UPDATE %I.%I SET %s = %s WHERE false', split_part(v_tbl,'.',1), split_part(v_tbl,'.',2), v_col, v_col);
|
|
118
|
+
END IF;
|
|
119
|
+
|
|
120
|
+
BEGIN
|
|
121
|
+
EXECUTE format('SET LOCAL ROLE %I', v_role);
|
|
122
|
+
BEGIN
|
|
123
|
+
EXECUTE v_stmt;
|
|
124
|
+
v_outcome := 'allowed';
|
|
125
|
+
EXCEPTION
|
|
126
|
+
WHEN insufficient_privilege THEN
|
|
127
|
+
v_outcome := CASE WHEN SQLERRM ILIKE '%row-level security%' THEN 'denied-rls' ELSE 'denied-privilege' END;
|
|
128
|
+
WHEN OTHERS THEN
|
|
129
|
+
v_outcome := 'allowed'; -- privilege passed; failed on data/constraint
|
|
130
|
+
END;
|
|
131
|
+
RESET ROLE;
|
|
132
|
+
EXCEPTION WHEN insufficient_privilege THEN
|
|
133
|
+
v_outcome := 'no-impersonate'; -- the SET ROLE itself was refused
|
|
134
|
+
RESET ROLE;
|
|
135
|
+
END;
|
|
136
|
+
|
|
137
|
+
INSERT INTO _authz_probe VALUES (v_role, v_tbl, v_cmd, v_outcome);
|
|
138
|
+
END LOOP;
|
|
139
|
+
END LOOP;
|
|
140
|
+
END LOOP;
|
|
141
|
+
END
|
|
142
|
+
$probe$;
|
|
143
|
+
`.trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read the probe outcomes. Runs in the same transaction as `buildProbeSql` (the temp
|
|
148
|
+
* table is `ON COMMIT DROP`), which the caller always rolls back so nothing persists.
|
|
149
|
+
*/
|
|
150
|
+
export const PROBE_SELECT_SQL =
|
|
151
|
+
'SELECT role, tbl AS table, command, outcome FROM _authz_probe ORDER BY tbl, role, command';
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Grant-completeness — the SECDEF-helper EXECUTE gap.
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Find SECURITY DEFINER functions whose owner cannot EXECUTE a helper their body calls.
|
|
159
|
+
*
|
|
160
|
+
* A SECDEF function runs as its owner; calling a helper needs the owner to hold EXECUTE
|
|
161
|
+
* on it. While every function is superuser-owned (db:migrate's default) the owner can
|
|
162
|
+
* execute everything, so the gaps are invisible — local tests pass. Normalize ownership
|
|
163
|
+
* to a non-superuser operator and the same call fails in production with 42501. This
|
|
164
|
+
* query surfaces those gaps from the catalog so they never reach prod unnoticed.
|
|
165
|
+
*
|
|
166
|
+
* Helper references are extracted from the function body by regex (schema-qualified
|
|
167
|
+
* `schema.fn(` calls); the join to pg_proc keeps only real functions, and
|
|
168
|
+
* has_function_privilege resolves ownership/PUBLIC/inheritance. Read-only — runs through
|
|
169
|
+
* the db:query action. A normalized, least-privilege database returns zero rows.
|
|
170
|
+
*/
|
|
171
|
+
export const GRANT_COMPLETENESS_SQL = `
|
|
172
|
+
WITH secdef AS (
|
|
173
|
+
SELECT p.oid, p.proowner, n.nspname || '.' || p.proname AS name, p.prosrc
|
|
174
|
+
FROM pg_proc p
|
|
175
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
176
|
+
WHERE p.prosecdef
|
|
177
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
178
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
179
|
+
AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e')
|
|
180
|
+
)
|
|
181
|
+
SELECT DISTINCT s.name AS secdef, ref.helper AS helper
|
|
182
|
+
FROM secdef s
|
|
183
|
+
CROSS JOIN LATERAL (
|
|
184
|
+
SELECT DISTINCT lower(m[1]) AS helper
|
|
185
|
+
FROM regexp_matches(s.prosrc, '([a-zA-Z_][a-zA-Z0-9_]*\\.[a-zA-Z_][a-zA-Z0-9_]*)\\s*\\(', 'g') AS m
|
|
186
|
+
) ref
|
|
187
|
+
JOIN pg_proc hp ON true
|
|
188
|
+
JOIN pg_namespace hn ON hn.oid = hp.pronamespace
|
|
189
|
+
WHERE lower(hn.nspname || '.' || hp.proname) = ref.helper
|
|
190
|
+
AND hp.oid <> s.oid
|
|
191
|
+
AND hn.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
192
|
+
AND NOT has_function_privilege(s.proowner, hp.oid, 'EXECUTE')
|
|
193
|
+
ORDER BY secdef, helper;
|
|
194
|
+
`.trim();
|
|
195
|
+
|
|
196
|
+
export interface GrantGap {
|
|
197
|
+
/** The SECDEF function whose owner is missing a grant. */
|
|
198
|
+
secdef: string;
|
|
199
|
+
/** The helper it calls but its owner cannot EXECUTE. */
|
|
200
|
+
helper: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function toGrantGap(row: any): GrantGap {
|
|
204
|
+
return { secdef: String(row.secdef), helper: String(row.helper) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Map a probe result row (from the SELECT) into a typed ProbeResult. */
|
|
208
|
+
export function toProbeResult(row: any): ProbeResult {
|
|
209
|
+
return {
|
|
210
|
+
role: String(row.role),
|
|
211
|
+
table: String(row.table),
|
|
212
|
+
command: String(row.command).toUpperCase() as ProbeCommand,
|
|
213
|
+
outcome: String(row.outcome) as ProbeOutcome,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Compare the live probe matrix against the contract's grants. A finding is a
|
|
219
|
+
* *disagreement* between declared authority and enforced behaviour:
|
|
220
|
+
* - `hole` — the role was ALLOWED something the contract does not grant. The
|
|
221
|
+
* dangerous one: enforced access exceeds the declaration.
|
|
222
|
+
* - `broken` — the contract grants it but the role was DENIED by privilege. The
|
|
223
|
+
* declaration is a dead letter; the feature is silently off.
|
|
224
|
+
* - `unprobed` — the probing connection could not SET ROLE into the role (a warning,
|
|
225
|
+
* not a verdict — grant the probe role membership to cover it).
|
|
226
|
+
* denied-rls counts as the privilege being present (RLS filtering rows is the policy's
|
|
227
|
+
* job, exercised behaviourally by the RPC layer, not a grant-level finding).
|
|
228
|
+
*/
|
|
229
|
+
export function evaluateRedTeam(contract: AuthzContract, results: ProbeResult[]): RedTeamFinding[] {
|
|
230
|
+
const tablesByName = new Map(contract.tables.map((t) => [t.table, t]));
|
|
231
|
+
const findings: RedTeamFinding[] = [];
|
|
232
|
+
|
|
233
|
+
for (const r of results) {
|
|
234
|
+
const table = tablesByName.get(r.table);
|
|
235
|
+
if (!table) continue; // a table not in the contract (e.g. ignored schema)
|
|
236
|
+
const granted = hasPrivilege(table, r.role, COMMAND_PRIVILEGE[r.command]);
|
|
237
|
+
const allowed = r.outcome === 'allowed' || r.outcome === 'denied-rls';
|
|
238
|
+
|
|
239
|
+
if (r.outcome === 'no-impersonate') {
|
|
240
|
+
findings.push({ severity: 'unprobed', role: r.role, table: r.table, command: r.command,
|
|
241
|
+
detail: `could not SET ROLE into ${r.role} — grant the probe connection membership to cover it` });
|
|
242
|
+
} else if (allowed && !granted) {
|
|
243
|
+
findings.push({ severity: 'hole', role: r.role, table: r.table, command: r.command,
|
|
244
|
+
detail: `${r.role} can ${r.command} ${r.table} but the contract grants no such privilege (enforcement exceeds declaration)` });
|
|
245
|
+
} else if (!allowed && granted) {
|
|
246
|
+
findings.push({ severity: 'broken', role: r.role, table: r.table, command: r.command,
|
|
247
|
+
detail: `contract grants ${r.role} ${r.command} on ${r.table} but the database denied it (declared but not enforced)` });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return findings;
|
|
251
|
+
}
|