@everystack/cli 0.2.39 → 0.2.40
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/README.md +27 -0
- package/package.json +8 -7
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/commands/db-authz.ts +206 -0
- package/src/cli/index.ts +17 -0
- package/src/cli/security-audit.ts +7 -0
- package/src/cli/security-catalog.ts +16 -1
- package/src/storage/index.ts +16 -3
- package/src/storage/s3.ts +66 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The human-readable view of the Authorization Contract.
|
|
3
|
+
*
|
|
4
|
+
* The JSON contract is machine-grade — correct for diff/test/probe, but a CTO cannot
|
|
5
|
+
* scan `(author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->>
|
|
6
|
+
* 'sub'::text))::uuid)` and confirm it means "owns the row". This renders the same
|
|
7
|
+
* contract as a role x action matrix with the predicates translated to plain English,
|
|
8
|
+
* so a human can read it and confirm or deny the rules match intent.
|
|
9
|
+
*
|
|
10
|
+
* `humanizePredicate` recognises the handful of shapes that make up ~all real policies
|
|
11
|
+
* (owner check, soft-delete guard, boolean, simple comparisons) — the same
|
|
12
|
+
* classification the v3 reverse-compiler (db:pull -> can()) needs, so it is not
|
|
13
|
+
* throwaway. Anything it does not recognise is shown verbatim, never hidden.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
|
|
17
|
+
import { hasPrivilege } from './authz-redteam.js';
|
|
18
|
+
|
|
19
|
+
/** True when the expression resolves to the signed-in user's id (any of the forms). */
|
|
20
|
+
function isCurrentUserExpr(s: string): boolean {
|
|
21
|
+
return /current_setting\([^)]*jwt\.claims/i.test(s)
|
|
22
|
+
|| /auth\.uid\(\)/i.test(s)
|
|
23
|
+
|| /auth\.jwt\(\)\s*->>\s*'sub'/i.test(s);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Strip one layer of redundant outer parentheses, repeatedly. */
|
|
27
|
+
function unwrap(s: string): string {
|
|
28
|
+
let t = s.trim();
|
|
29
|
+
while (t.startsWith('(') && t.endsWith(')') && balanced(t.slice(1, -1))) {
|
|
30
|
+
t = t.slice(1, -1).trim();
|
|
31
|
+
}
|
|
32
|
+
return t;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when every '(' in s closes before the end — i.e. the outer parens were redundant. */
|
|
36
|
+
function balanced(s: string): boolean {
|
|
37
|
+
let depth = 0;
|
|
38
|
+
for (const ch of s) {
|
|
39
|
+
if (ch === '(') depth++;
|
|
40
|
+
else if (ch === ')') { if (--depth < 0) return false; }
|
|
41
|
+
}
|
|
42
|
+
return depth === 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Split on a top-level keyword (OR / AND), respecting parentheses. */
|
|
46
|
+
function splitTop(s: string, keyword: 'OR' | 'AND'): string[] {
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
let depth = 0;
|
|
49
|
+
let last = 0;
|
|
50
|
+
const re = new RegExp(`\\b${keyword}\\b`, 'gi');
|
|
51
|
+
for (let i = 0; i < s.length; i++) {
|
|
52
|
+
if (s[i] === '(') depth++;
|
|
53
|
+
else if (s[i] === ')') depth--;
|
|
54
|
+
else if (depth === 0) {
|
|
55
|
+
re.lastIndex = i;
|
|
56
|
+
const m = re.exec(s);
|
|
57
|
+
if (m && m.index === i) {
|
|
58
|
+
parts.push(s.slice(last, i));
|
|
59
|
+
i += m[0].length - 1;
|
|
60
|
+
last = i + 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
parts.push(s.slice(last));
|
|
65
|
+
return parts.map((p) => p.trim()).filter(Boolean);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One comparison/atom -> a phrase. Falls back to the raw SQL in backticks. */
|
|
69
|
+
function ownerOf(col: string): string {
|
|
70
|
+
return `owner (\`${col.replace(/"/g, '')}\`)`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function humanizeAtom(atom: string): string {
|
|
74
|
+
const a = unwrap(atom);
|
|
75
|
+
if (/^true$/i.test(a)) return 'any row';
|
|
76
|
+
if (/^false$/i.test(a)) return 'no row';
|
|
77
|
+
|
|
78
|
+
// <col> IN ( SELECT ... FROM <parent> WHERE ... <current user> ... ) — parent-visibility
|
|
79
|
+
// inheritance: you may touch this row when you own the parent it links to.
|
|
80
|
+
let m = a.match(/^([\w".]+)\s+IN\s*\(\s*SELECT[\s\S]*?\bFROM\s+([\w".]+)/i);
|
|
81
|
+
if (m && isCurrentUserExpr(a)) {
|
|
82
|
+
return `owner of linked \`${m[2].replace(/"/g, '')}\` (via \`${m[1].replace(/"/g, '')}\`)`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// <col> = <current-user-id-expr> (in either order) -> ownership. Anchored on the
|
|
86
|
+
// column and the start of the identity expression, so the trailing cast/paren noise
|
|
87
|
+
// (`::uuid`, `))`) never matters.
|
|
88
|
+
if (isCurrentUserExpr(a)) {
|
|
89
|
+
m = a.match(/^\(*\s*([\w".]+)\s*=\s*\(*\s*(?:current_setting|auth\.)/i);
|
|
90
|
+
if (m) return ownerOf(m[1]);
|
|
91
|
+
m = a.match(/=\s*([\w".]+)\s*\)*$/);
|
|
92
|
+
if (m) return ownerOf(m[1]);
|
|
93
|
+
// A subquery / EXISTS keyed on the current user that we can't reduce cleanly — be
|
|
94
|
+
// honest that it depends on the user via related data, and point at the JSON.
|
|
95
|
+
if (/\bselect\b|\bexists\b/i.test(a)) return 'depends on the signed-in user (via a subquery — see JSON)';
|
|
96
|
+
return 'the current user';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// a bare boolean column used as a predicate (e.g. `active`) means "<col> is true"
|
|
100
|
+
if (/^[\w".]+$/.test(a)) return `\`${a.replace(/"/g, '')}\` is true`;
|
|
101
|
+
|
|
102
|
+
// soft-delete / null guards
|
|
103
|
+
m = a.match(/^([\w".]+)\s+IS\s+NULL$/i);
|
|
104
|
+
if (m) return /delet/i.test(m[1]) ? 'not deleted' : `\`${m[1].replace(/"/g, '')}\` is empty`;
|
|
105
|
+
m = a.match(/^([\w".]+)\s+IS\s+NOT\s+NULL$/i);
|
|
106
|
+
if (m) return `\`${m[1].replace(/"/g, '')}\` is set`;
|
|
107
|
+
|
|
108
|
+
// simple equality / membership against a literal
|
|
109
|
+
m = a.match(/^([\w".]+)\s*=\s*('[^']*'|\w+)$/i);
|
|
110
|
+
if (m) return `\`${m[1].replace(/"/g, '')}\` = ${m[2]}`;
|
|
111
|
+
m = a.match(/^([\w".]+)\s+IN\s+(\(.+\))$/i);
|
|
112
|
+
if (m) return `\`${m[1].replace(/"/g, '')}\` in ${m[2]}`;
|
|
113
|
+
|
|
114
|
+
return `\`${a}\``;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Translate an RLS predicate to a plain-English phrase. null/empty -> "any row".
|
|
119
|
+
* Handles OR / AND combinations of the recognised atoms; unknown atoms are shown raw.
|
|
120
|
+
*/
|
|
121
|
+
export function humanizePredicate(sql: string | null): string {
|
|
122
|
+
if (!sql || !sql.trim()) return 'any row';
|
|
123
|
+
const expr = unwrap(sql);
|
|
124
|
+
|
|
125
|
+
const ors = splitTop(expr, 'OR');
|
|
126
|
+
if (ors.length > 1) return ors.map(renderConj).join(' OR ');
|
|
127
|
+
return renderConj(expr);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderConj(s: string): string {
|
|
131
|
+
const ands = splitTop(s, 'AND');
|
|
132
|
+
if (ands.length > 1) return ands.map(humanizeAtom).join(' AND ');
|
|
133
|
+
return humanizeAtom(s);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// The matrix renderer.
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
const ACTIONS: { label: string; command: string; clause: 'using' | 'check' }[] = [
|
|
141
|
+
{ label: 'Read', command: 'SELECT', clause: 'using' },
|
|
142
|
+
{ label: 'Create', command: 'INSERT', clause: 'check' },
|
|
143
|
+
{ label: 'Update', command: 'UPDATE', clause: 'using' },
|
|
144
|
+
{ label: 'Delete', command: 'DELETE', clause: 'using' },
|
|
145
|
+
];
|
|
146
|
+
const COMMAND_PRIVILEGE: Record<string, string> = { SELECT: 'SELECT', INSERT: 'INSERT', UPDATE: 'UPDATE', DELETE: 'DELETE' };
|
|
147
|
+
|
|
148
|
+
/** Roles to show as rows: the table's grantees (and column-grantees), minus PUBLIC. */
|
|
149
|
+
function tableRoles(t: TableContract): string[] {
|
|
150
|
+
const roles = new Set<string>();
|
|
151
|
+
Object.keys(t.grants).forEach((g) => g.toUpperCase() !== 'PUBLIC' && roles.add(g));
|
|
152
|
+
Object.keys(t.columnGrants ?? {}).forEach((g) => g.toUpperCase() !== 'PUBLIC' && roles.add(g));
|
|
153
|
+
return [...roles].sort();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function policiesFor(t: TableContract, role: string, command: string): { permissive: PolicyContract[]; restrictive: PolicyContract[] } {
|
|
157
|
+
const applies = (p: PolicyContract) =>
|
|
158
|
+
(p.command === command || p.command === 'ALL') &&
|
|
159
|
+
(p.roles.includes(role) || p.roles.includes('public'));
|
|
160
|
+
return {
|
|
161
|
+
permissive: t.policies.filter((p) => applies(p) && p.permissive),
|
|
162
|
+
restrictive: t.policies.filter((p) => applies(p) && !p.permissive),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One matrix cell for (table, role, action). */
|
|
167
|
+
export function renderCell(t: TableContract, role: string, action: typeof ACTIONS[number]): string {
|
|
168
|
+
const priv = COMMAND_PRIVILEGE[action.command];
|
|
169
|
+
if (!hasPrivilege(t, role, priv)) return '—';
|
|
170
|
+
if (!t.rls.enabled) return '✓ all';
|
|
171
|
+
|
|
172
|
+
const { permissive, restrictive } = policiesFor(t, role, action.command);
|
|
173
|
+
if (permissive.length === 0) return '⚠ no policy';
|
|
174
|
+
|
|
175
|
+
const conds = permissive.map((p) => humanizePredicate(p[action.clause]));
|
|
176
|
+
let cell = conds.some((c) => c === 'any row') ? '✓ all' : `✓ ${[...new Set(conds)].join(' OR ')}`;
|
|
177
|
+
if (restrictive.length) {
|
|
178
|
+
cell += ` (only if ${restrictive.map((p) => humanizePredicate(p[action.clause])).join(' AND ')})`;
|
|
179
|
+
}
|
|
180
|
+
// Column-restricted read/write: note the columns when access is column-scoped only.
|
|
181
|
+
const cols = t.columnGrants?.[role]?.[priv];
|
|
182
|
+
if (cols?.length && !(t.grants[role] ?? []).includes(priv)) {
|
|
183
|
+
cell += ` — cols: ${cols.join(', ')}`;
|
|
184
|
+
}
|
|
185
|
+
return cell;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Render the whole contract as a human-readable Markdown review document. */
|
|
189
|
+
export function renderContractMarkdown(contract: AuthzContract): string {
|
|
190
|
+
const policyCount = contract.tables.reduce((n, t) => n + t.policies.length, 0);
|
|
191
|
+
const lines: string[] = [];
|
|
192
|
+
|
|
193
|
+
lines.push('# Authorization Review');
|
|
194
|
+
lines.push('');
|
|
195
|
+
lines.push('> The human-readable view of `authz/*.contract.json`. Read it to confirm the rules');
|
|
196
|
+
lines.push('> match intent — the JSON is the machine-checked source, this is generated from it.');
|
|
197
|
+
lines.push('> Regenerate with `everystack db:authz:report`; never hand-edit.');
|
|
198
|
+
lines.push('');
|
|
199
|
+
lines.push(`_${contract.tables.length} tables · ${policyCount} policies · ${contract.functions.length} SECURITY DEFINER functions._`);
|
|
200
|
+
lines.push('');
|
|
201
|
+
lines.push('**Legend:**');
|
|
202
|
+
lines.push('');
|
|
203
|
+
lines.push('- **✓** — allowed, followed by the condition a row must satisfy');
|
|
204
|
+
lines.push('- **—** — not granted');
|
|
205
|
+
lines.push('- **⚠ no policy** — the privilege is granted but no RLS policy matches, so no rows are returned (usually a mistake)');
|
|
206
|
+
lines.push('- **owner (`col`)** — the row\'s `col` equals the signed-in user');
|
|
207
|
+
lines.push('- **owner of linked `t` (via `col`)** — you may act when you own the `t` row that `col` points to');
|
|
208
|
+
lines.push('');
|
|
209
|
+
|
|
210
|
+
for (const t of contract.tables) {
|
|
211
|
+
const posture = t.rls.enabled ? (t.rls.forced ? 'enforced (forced)' : 'enabled (not forced)') : '**OFF — no row security**';
|
|
212
|
+
lines.push(`## ${t.table}`);
|
|
213
|
+
lines.push(`RLS: ${posture}`);
|
|
214
|
+
lines.push('');
|
|
215
|
+
const roles = tableRoles(t);
|
|
216
|
+
if (roles.length === 0) {
|
|
217
|
+
lines.push('_No roles granted any access._');
|
|
218
|
+
lines.push('');
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
lines.push(`| Role | ${ACTIONS.map((a) => a.label).join(' | ')} |`);
|
|
222
|
+
lines.push(`|---|${ACTIONS.map(() => '---').join('|')}|`);
|
|
223
|
+
for (const role of roles) {
|
|
224
|
+
lines.push(`| \`${role}\` | ${ACTIONS.map((a) => renderCell(t, role, a)).join(' | ')} |`);
|
|
225
|
+
}
|
|
226
|
+
lines.push('');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (contract.functions.length) {
|
|
230
|
+
lines.push('## SECURITY DEFINER functions');
|
|
231
|
+
lines.push('');
|
|
232
|
+
lines.push('These run with their owner\'s privileges, **above row security** — their own bodies enforce access. Review each one deliberately. A missing `search_path` is a hijack vector; an owner that bypasses RLS (superuser / BYPASSRLS) makes the function run above RLS as that role — the maximum blast radius. `db:migrate` often leaves functions superuser-owned; normalize ownership to a non-superuser operator.');
|
|
233
|
+
lines.push('');
|
|
234
|
+
const elevated = contract.functions.filter((f) => f.ownerBypassesRls);
|
|
235
|
+
if (elevated.length) {
|
|
236
|
+
lines.push(`> **⚠ ${elevated.length} function(s) are owned by a role that bypasses RLS** — they run above row security regardless of \`search_path\`. Normalize their ownership.`);
|
|
237
|
+
lines.push('');
|
|
238
|
+
}
|
|
239
|
+
for (const f of contract.functions) {
|
|
240
|
+
const sp = f.hasSearchPath ? 'search_path pinned ✓' : '**search_path NOT pinned ⚠**';
|
|
241
|
+
const own = f.ownerBypassesRls ? ' · **owner bypasses RLS ⚠**' : '';
|
|
242
|
+
lines.push(`- \`${f.name}\` — ${sp}${own}`);
|
|
243
|
+
}
|
|
244
|
+
lines.push('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|