@everystack/cli 0.2.37 → 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/LICENSE +681 -0
- package/README.md +32 -1
- package/package.json +11 -1
- 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/commands/db.ts +215 -5
- package/src/cli/commands/logs.ts +67 -33
- package/src/cli/commands/security.ts +185 -0
- package/src/cli/index.ts +34 -2
- package/src/cli/security-audit.ts +405 -0
- package/src/cli/security-catalog.ts +170 -0
- package/src/storage/index.ts +16 -3
- package/src/storage/s3.ts +66 -1
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Authorization Contract — introspection + format.
|
|
3
|
+
*
|
|
4
|
+
* The brownfield slice of the Model architecture (docs/plans/v3-authz-contract.md):
|
|
5
|
+
* authorization today lives scattered across migrations and the live database, with
|
|
6
|
+
* no artifact you can read, diff, or test against. This module is the read layer that
|
|
7
|
+
* extracts the *effective* authorization into a single reviewable shape.
|
|
8
|
+
*
|
|
9
|
+
* Two new introspection queries live here — the policy bodies (`pg_policies`) and the
|
|
10
|
+
* full per-grantee privilege matrix (`aclexplode` over `relacl`) — the layer neither
|
|
11
|
+
* `security-catalog` (functions + relations) nor `db:doctor` (RLS flags + role split)
|
|
12
|
+
* reads today. Same contract as security-catalog: the SQL runs through the ops Lambda
|
|
13
|
+
* (`db:query`); the mappers here are pure and unit-tested without a database.
|
|
14
|
+
*
|
|
15
|
+
* Frozen decisions (see the doc's "Open decisions"):
|
|
16
|
+
* - SECDEF gates are enumerate-only; behaviour is proven by the RPC test layer.
|
|
17
|
+
* - Per-table file layout (`authz/<table>.contract.json`).
|
|
18
|
+
* - Predicate text is stored strict — the Postgres deparser output (pg_policies
|
|
19
|
+
* already applies pg_get_expr), which is deterministic for a given parsed
|
|
20
|
+
* expression regardless of the source whitespace/casing.
|
|
21
|
+
* - The contract is a strict sublayer of the Model: defineModel compiles to it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { parsePgArray } from './security-catalog.js';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// The contract format — the frozen, reviewable, version-controlled shape.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Per-table authorization: the RLS posture, grants, policies, and column exposure. */
|
|
31
|
+
export interface TableContract {
|
|
32
|
+
/** Schema-qualified: `public.posts`. */
|
|
33
|
+
table: string;
|
|
34
|
+
rls: {
|
|
35
|
+
/** `relrowsecurity` — RLS is enabled on the table. */
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
/** `relforcerowsecurity` — enforced even for the table owner (load-bearing under the credential split). */
|
|
38
|
+
forced: boolean;
|
|
39
|
+
};
|
|
40
|
+
/** Grantee (role name, or `PUBLIC`) -> the table-level privileges granted to it. */
|
|
41
|
+
grants: Record<string, string[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Column-scoped grants (`GRANT SELECT (col) ON t TO role`): grantee -> privilege ->
|
|
44
|
+
* sorted column list. Postgres allows column privileges only for SELECT / INSERT /
|
|
45
|
+
* UPDATE / REFERENCES. This is how least-privilege exposes a subset of a table (e.g.
|
|
46
|
+
* `auth.users` to `authenticated` as id/email/role only, never the password hash), so
|
|
47
|
+
* it is first-class in the contract — a table-level grant view alone would miss it.
|
|
48
|
+
*/
|
|
49
|
+
columnGrants?: Record<string, Record<string, string[]>>;
|
|
50
|
+
policies: PolicyContract[];
|
|
51
|
+
/**
|
|
52
|
+
* Column exposure (hiddenColumns / `.private()`, protectedFields / `.readonly()`).
|
|
53
|
+
* NOT a database fact — it lives in the handler/Model config — so the DB pull omits
|
|
54
|
+
* it entirely. Present only when a handler-config/Model producer fills it. Optional
|
|
55
|
+
* so the DB pull never claims an empty allowlist a diff would fail to flag.
|
|
56
|
+
*/
|
|
57
|
+
columns?: {
|
|
58
|
+
/** Never in API responses. */
|
|
59
|
+
hidden: string[];
|
|
60
|
+
/** Never accepted on write. */
|
|
61
|
+
protected: string[];
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One RLS policy, normalized to the deparsed predicate text. */
|
|
66
|
+
export interface PolicyContract {
|
|
67
|
+
name: string;
|
|
68
|
+
/** `ALL` | `SELECT` | `INSERT` | `UPDATE` | `DELETE`. */
|
|
69
|
+
command: PolicyCommand;
|
|
70
|
+
/** Roles the policy applies to; `['public']` for a PUBLIC policy. */
|
|
71
|
+
roles: string[];
|
|
72
|
+
/**
|
|
73
|
+
* PERMISSIVE (OR-combined, the default) vs RESTRICTIVE (AND-combined). A restrictive
|
|
74
|
+
* policy is a different authorization than a permissive one with identical predicates,
|
|
75
|
+
* so it is part of the contract — omitting it would make the two look the same.
|
|
76
|
+
*/
|
|
77
|
+
permissive: boolean;
|
|
78
|
+
/** `USING` predicate (deparsed), or null when the command has none (e.g. INSERT). */
|
|
79
|
+
using: string | null;
|
|
80
|
+
/** `WITH CHECK` predicate (deparsed), or null when absent. */
|
|
81
|
+
check: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* One function, enumerated. The cross-table authorization that RLS does not hold lives
|
|
86
|
+
* in SECURITY DEFINER functions as imperative gates; the contract records that they
|
|
87
|
+
* exist and are correctly shaped (secdef, pinned search_path), and the RPC test layer
|
|
88
|
+
* proves the gate behaviourally. The imperative body is deliberately not modeled.
|
|
89
|
+
*/
|
|
90
|
+
export interface FunctionContract {
|
|
91
|
+
/** Schema-qualified: `public.transfer_post_ownership`. */
|
|
92
|
+
name: string;
|
|
93
|
+
securityDefiner: boolean;
|
|
94
|
+
/** A SECDEF function without a pinned `search_path` is a hijack vector. */
|
|
95
|
+
hasSearchPath: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* True when the owner runs above RLS (superuser / BYPASSRLS / rds_superuser member).
|
|
98
|
+
* The owner *name* is deliberately not committed — it is environment-specific (local
|
|
99
|
+
* dev user vs deployed master vs a normalized operator), so it would false-fire the
|
|
100
|
+
* diff. This boolean is the portable, reviewable security fact.
|
|
101
|
+
* A SECURITY DEFINER function owned by such a role runs above RLS as that role on
|
|
102
|
+
* every call — the maximum blast radius SECDEF is meant to reduce. `db:migrate`
|
|
103
|
+
* commonly leaves functions superuser-owned; normalize ownership to a non-superuser
|
|
104
|
+
* operator. Pinned search_path is necessary but not sufficient.
|
|
105
|
+
*/
|
|
106
|
+
ownerBypassesRls?: boolean;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The whole contract: every table and every function, as introspected. */
|
|
110
|
+
export interface AuthzContract {
|
|
111
|
+
tables: TableContract[];
|
|
112
|
+
functions: FunctionContract[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type PolicyCommand = 'ALL' | 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE';
|
|
116
|
+
|
|
117
|
+
/** Runs one read-only SQL string and returns the rows. The backend (ops Lambda or a
|
|
118
|
+
* direct connection) is the command's choice; the core stays backend-agnostic. */
|
|
119
|
+
export type QueryRunner = (sql: string) => Promise<any[]>;
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Introspection — the two new queries.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Every RLS policy with its deparsed predicates. `pg_policies` already applies
|
|
127
|
+
* `pg_get_expr` to `qual`/`with_check`, so the text is canonical for a given parsed
|
|
128
|
+
* expression. `roles` arrives as a `name[]` ({anon,authenticated}); `cmd` is the text
|
|
129
|
+
* form (`SELECT`, `ALL`, …); `permissive` is `PERMISSIVE`/`RESTRICTIVE`.
|
|
130
|
+
*/
|
|
131
|
+
export const POLICIES_SQL = `
|
|
132
|
+
SELECT
|
|
133
|
+
schemaname AS schema,
|
|
134
|
+
tablename AS "table",
|
|
135
|
+
policyname AS name,
|
|
136
|
+
cmd AS command,
|
|
137
|
+
permissive,
|
|
138
|
+
roles,
|
|
139
|
+
qual AS using_expr,
|
|
140
|
+
with_check AS check_expr
|
|
141
|
+
FROM pg_policies
|
|
142
|
+
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
143
|
+
AND schemaname NOT LIKE 'pg_%'
|
|
144
|
+
ORDER BY schemaname, tablename, policyname;
|
|
145
|
+
`.trim();
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The full per-grantee privilege matrix — what the schema *declares*, not what a role
|
|
149
|
+
* effectively inherits (inheritance resolution belongs in the red-team test, which
|
|
150
|
+
* SET ROLEs and tries). `aclexplode(relacl)` expands the ACL into one row per
|
|
151
|
+
* (grantee, privilege); a NULL `relacl` means only the owner has privileges (default),
|
|
152
|
+
* so the table simply produces no grant rows — correctly "no broad grants." Grantee
|
|
153
|
+
* OID 0 is PUBLIC. Extension-owned tables are excluded via pg_depend, matching the
|
|
154
|
+
* relations query so vendor objects never need a waiver.
|
|
155
|
+
*
|
|
156
|
+
* The table OWNER's self-grant is excluded: it is environment-specific (the local dev
|
|
157
|
+
* user locally, the master/operator role when deployed) and tautological (the owner
|
|
158
|
+
* implicitly holds every privilege), so committing it would make the contract
|
|
159
|
+
* non-portable and drift on every environment. A least-priv app role like
|
|
160
|
+
* `authenticator` is *not* the owner and is kept — a direct grant to it is a real
|
|
161
|
+
* (and usually wrong) authorization fact the contract should surface.
|
|
162
|
+
*/
|
|
163
|
+
export const GRANTS_SQL = `
|
|
164
|
+
SELECT
|
|
165
|
+
n.nspname AS schema,
|
|
166
|
+
c.relname AS "table",
|
|
167
|
+
COALESCE(r.rolname, 'PUBLIC') AS grantee,
|
|
168
|
+
array_agg(DISTINCT a.privilege_type ORDER BY a.privilege_type) AS privileges
|
|
169
|
+
FROM pg_class c
|
|
170
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
171
|
+
CROSS JOIN LATERAL aclexplode(c.relacl) a
|
|
172
|
+
LEFT JOIN pg_roles r ON r.oid = a.grantee
|
|
173
|
+
WHERE c.relkind IN ('r', 'v', 'm')
|
|
174
|
+
AND c.relacl IS NOT NULL
|
|
175
|
+
AND a.grantee <> c.relowner
|
|
176
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
177
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
178
|
+
AND NOT EXISTS (
|
|
179
|
+
SELECT 1 FROM pg_depend d
|
|
180
|
+
WHERE d.objid = c.oid AND d.deptype = 'e'
|
|
181
|
+
)
|
|
182
|
+
GROUP BY n.nspname, c.relname, r.rolname
|
|
183
|
+
ORDER BY n.nspname, c.relname, grantee;
|
|
184
|
+
`.trim();
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Column-scoped grants from `pg_attribute.attacl` — the privilege layer `relacl` (and
|
|
188
|
+
* thus GRANTS_SQL) cannot see. `GRANT SELECT (id, email) ON t TO role` lives here, not
|
|
189
|
+
* in the table ACL. Owner self-grants and extension tables are excluded to match
|
|
190
|
+
* GRANTS_SQL, so the two compose into one grant picture without double-counting.
|
|
191
|
+
*/
|
|
192
|
+
export const COLUMN_GRANTS_SQL = `
|
|
193
|
+
SELECT
|
|
194
|
+
n.nspname AS schema,
|
|
195
|
+
c.relname AS "table",
|
|
196
|
+
a.attname AS "column",
|
|
197
|
+
COALESCE(r.rolname, 'PUBLIC') AS grantee,
|
|
198
|
+
acl.privilege_type AS privilege
|
|
199
|
+
FROM pg_class c
|
|
200
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
201
|
+
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
|
|
202
|
+
CROSS JOIN LATERAL aclexplode(a.attacl) acl
|
|
203
|
+
LEFT JOIN pg_roles r ON r.oid = acl.grantee
|
|
204
|
+
WHERE a.attacl IS NOT NULL
|
|
205
|
+
AND acl.grantee <> c.relowner
|
|
206
|
+
AND c.relkind IN ('r', 'v', 'm')
|
|
207
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
208
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
209
|
+
AND NOT EXISTS (
|
|
210
|
+
SELECT 1 FROM pg_depend d
|
|
211
|
+
WHERE d.objid = c.oid AND d.deptype = 'e'
|
|
212
|
+
)
|
|
213
|
+
ORDER BY n.nspname, c.relname, grantee, privilege, a.attname;
|
|
214
|
+
`.trim();
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Every base table with its RLS posture — the table enumerator for the contract.
|
|
218
|
+
* `relrowsecurity` / `relforcerowsecurity` give enabled/forced. A table absent from
|
|
219
|
+
* this set has no RLS row to report; a table present with `enabled: false` is the
|
|
220
|
+
* dangerous case the contract makes visible (an ungated table). Extension-owned tables
|
|
221
|
+
* are excluded via pg_depend, matching the other queries.
|
|
222
|
+
*/
|
|
223
|
+
export const RLS_SQL = `
|
|
224
|
+
SELECT
|
|
225
|
+
n.nspname AS schema,
|
|
226
|
+
c.relname AS "table",
|
|
227
|
+
c.relrowsecurity AS enabled,
|
|
228
|
+
c.relforcerowsecurity AS forced
|
|
229
|
+
FROM pg_class c
|
|
230
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
231
|
+
WHERE c.relkind = 'r'
|
|
232
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
233
|
+
AND n.nspname NOT LIKE 'pg_%'
|
|
234
|
+
AND NOT EXISTS (
|
|
235
|
+
SELECT 1 FROM pg_depend d
|
|
236
|
+
WHERE d.objid = c.oid AND d.deptype = 'e'
|
|
237
|
+
)
|
|
238
|
+
ORDER BY n.nspname, c.relname;
|
|
239
|
+
`.trim();
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Pure mappers — one catalog row -> one descriptor.
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/** Coerce a Postgres boolean (true | 't' | 'true' | 1) into a JS boolean. */
|
|
246
|
+
function truthy(v: unknown): boolean {
|
|
247
|
+
return v === true || v === 't' || v === 'true' || v === 1 || v === '1';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A non-empty predicate, trimmed; null/empty/whitespace -> null (a missing clause). */
|
|
251
|
+
function predicate(v: unknown): string | null {
|
|
252
|
+
if (v == null) return null;
|
|
253
|
+
const s = String(v).trim();
|
|
254
|
+
return s.length > 0 ? s : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface PolicyRow {
|
|
258
|
+
schema: string;
|
|
259
|
+
table: string;
|
|
260
|
+
name: string;
|
|
261
|
+
command: unknown;
|
|
262
|
+
permissive: unknown;
|
|
263
|
+
roles: unknown;
|
|
264
|
+
using_expr: unknown;
|
|
265
|
+
check_expr: unknown;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Map a `pg_policies` row to a policy descriptor keyed by its schema-qualified table. */
|
|
269
|
+
export function policyRowToDescriptor(row: PolicyRow): { table: string; policy: PolicyContract } {
|
|
270
|
+
const command = String(row.command ?? '').toUpperCase();
|
|
271
|
+
return {
|
|
272
|
+
table: `${row.schema}.${row.table}`,
|
|
273
|
+
policy: {
|
|
274
|
+
name: row.name,
|
|
275
|
+
command: (command || 'ALL') as PolicyCommand,
|
|
276
|
+
// pg_policies.permissive is the text 'PERMISSIVE' | 'RESTRICTIVE'.
|
|
277
|
+
permissive: String(row.permissive ?? 'PERMISSIVE').toUpperCase() !== 'RESTRICTIVE',
|
|
278
|
+
roles: parsePgArray(row.roles) ?? [],
|
|
279
|
+
using: predicate(row.using_expr),
|
|
280
|
+
check: predicate(row.check_expr),
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface GrantRow {
|
|
286
|
+
schema: string;
|
|
287
|
+
table: string;
|
|
288
|
+
grantee: unknown;
|
|
289
|
+
privileges: unknown;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Map an `aclexplode` row to a grant descriptor keyed by its schema-qualified table. */
|
|
293
|
+
export function grantRowToDescriptor(row: GrantRow): {
|
|
294
|
+
table: string;
|
|
295
|
+
grantee: string;
|
|
296
|
+
privileges: string[];
|
|
297
|
+
} {
|
|
298
|
+
return {
|
|
299
|
+
table: `${row.schema}.${row.table}`,
|
|
300
|
+
grantee: String(row.grantee ?? 'PUBLIC'),
|
|
301
|
+
privileges: parsePgArray(row.privileges) ?? [],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface ColumnGrantRow {
|
|
306
|
+
schema: string;
|
|
307
|
+
table: string;
|
|
308
|
+
column: string;
|
|
309
|
+
grantee: unknown;
|
|
310
|
+
privilege: unknown;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Map a column-grant row to its schema-qualified table + grantee/privilege/column. */
|
|
314
|
+
export function columnGrantRowToDescriptor(row: ColumnGrantRow): {
|
|
315
|
+
table: string;
|
|
316
|
+
grantee: string;
|
|
317
|
+
privilege: string;
|
|
318
|
+
column: string;
|
|
319
|
+
} {
|
|
320
|
+
return {
|
|
321
|
+
table: `${row.schema}.${row.table}`,
|
|
322
|
+
grantee: String(row.grantee ?? 'PUBLIC'),
|
|
323
|
+
privilege: String(row.privilege ?? '').toUpperCase(),
|
|
324
|
+
column: String(row.column),
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface RlsRow {
|
|
329
|
+
schema: string;
|
|
330
|
+
table: string;
|
|
331
|
+
enabled: unknown;
|
|
332
|
+
forced: unknown;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Map a `pg_class` RLS-flags row to its schema-qualified table + posture. */
|
|
336
|
+
export function rlsRowToDescriptor(row: RlsRow): {
|
|
337
|
+
table: string;
|
|
338
|
+
rls: { enabled: boolean; forced: boolean };
|
|
339
|
+
} {
|
|
340
|
+
return {
|
|
341
|
+
table: `${row.schema}.${row.table}`,
|
|
342
|
+
rls: { enabled: truthy(row.enabled), forced: truthy(row.forced) },
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Assembly — fold the row sets into one normalized, deterministic contract.
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Schemas owned by migration tooling, not the app — excluded from the contract.
|
|
352
|
+
* `drizzle` holds drizzle-kit's `__drizzle_migrations` journal: it has no policies, no
|
|
353
|
+
* meaningful grants, and exists only when migrations ran through drizzle's migrator
|
|
354
|
+
* (so it appears on a deployed stage but not on a raw-applied local DB — pure noise
|
|
355
|
+
* that would read as local↔remote drift). Same intent as the pg_depend extension filter.
|
|
356
|
+
*/
|
|
357
|
+
export const IGNORED_SCHEMAS = new Set(['drizzle']);
|
|
358
|
+
|
|
359
|
+
export interface ContractRows {
|
|
360
|
+
rls: RlsRow[];
|
|
361
|
+
policies: PolicyRow[];
|
|
362
|
+
grants: GrantRow[];
|
|
363
|
+
columnGrants?: ColumnGrantRow[];
|
|
364
|
+
/** Already-mapped function descriptors (from security-catalog's FUNCTIONS_SQL). */
|
|
365
|
+
functions: { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean; owner?: string; ownerBypassesRls?: boolean }[];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Fold introspected rows into the contract. Deterministic: tables sorted by name,
|
|
370
|
+
* policies by name, grant grantees and privilege lists sorted — so the serialized
|
|
371
|
+
* files are byte-stable and a re-pull of an unchanged database is a no-op diff.
|
|
372
|
+
*
|
|
373
|
+
* Column exposure (hidden/protected) is *not* a database fact — it lives in the
|
|
374
|
+
* handler/Model config — so the DB pull omits `columns` entirely rather than claim an
|
|
375
|
+
* empty allowlist a diff would never flag. Only SECURITY DEFINER functions are
|
|
376
|
+
* recorded: an INVOKER function runs as the caller under RLS and is not an
|
|
377
|
+
* authorization decision point; a SECDEF function runs above RLS and is.
|
|
378
|
+
*/
|
|
379
|
+
export function assembleContract(rows: ContractRows): AuthzContract {
|
|
380
|
+
const tables = new Map<string, TableContract>();
|
|
381
|
+
for (const row of rows.rls) {
|
|
382
|
+
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
383
|
+
const { table, rls } = rlsRowToDescriptor(row);
|
|
384
|
+
tables.set(table, { table, rls, grants: {}, policies: [] });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A grant or policy on a table the RLS enumerator didn't list (e.g. a view, excluded
|
|
388
|
+
// above) is dropped rather than synthesizing a table with unknown RLS posture.
|
|
389
|
+
for (const row of rows.grants) {
|
|
390
|
+
const { table, grantee, privileges } = grantRowToDescriptor(row);
|
|
391
|
+
const t = tables.get(table);
|
|
392
|
+
if (t) t.grants[grantee] = [...privileges].sort();
|
|
393
|
+
}
|
|
394
|
+
for (const row of rows.policies) {
|
|
395
|
+
const { table, policy } = policyRowToDescriptor(row);
|
|
396
|
+
const t = tables.get(table);
|
|
397
|
+
if (t) t.policies.push({ ...policy, roles: [...policy.roles].sort() });
|
|
398
|
+
}
|
|
399
|
+
for (const row of rows.columnGrants ?? []) {
|
|
400
|
+
const { table, grantee, privilege, column } = columnGrantRowToDescriptor(row);
|
|
401
|
+
const t = tables.get(table);
|
|
402
|
+
if (!t) continue;
|
|
403
|
+
const byGrantee = (t.columnGrants ??= {});
|
|
404
|
+
const byPriv = (byGrantee[grantee] ??= {});
|
|
405
|
+
(byPriv[privilege] ??= []).push(column);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
for (const t of tables.values()) {
|
|
409
|
+
t.policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
410
|
+
for (const byPriv of Object.values(t.columnGrants ?? {})) {
|
|
411
|
+
for (const cols of Object.values(byPriv)) cols.sort();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const functions: FunctionContract[] = rows.functions
|
|
416
|
+
.filter((f) => f.securityDefiner && !IGNORED_SCHEMAS.has(f.schema))
|
|
417
|
+
.map((f) => ({
|
|
418
|
+
name: `${f.schema}.${f.name}`,
|
|
419
|
+
securityDefiner: true,
|
|
420
|
+
hasSearchPath: f.hasSearchPath,
|
|
421
|
+
...(f.ownerBypassesRls !== undefined ? { ownerBypassesRls: f.ownerBypassesRls } : {}),
|
|
422
|
+
}))
|
|
423
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
tables: [...tables.values()].sort((a, b) => a.table.localeCompare(b.table)),
|
|
427
|
+
functions,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Run every introspection query through one runner and assemble the contract. */
|
|
432
|
+
export async function introspectContract(
|
|
433
|
+
run: QueryRunner,
|
|
434
|
+
mapFunctionRow: (row: any) => { schema: string; name: string; securityDefiner: boolean; hasSearchPath: boolean },
|
|
435
|
+
functionsSql: string,
|
|
436
|
+
): Promise<AuthzContract> {
|
|
437
|
+
const [rls, policies, grants, columnGrants, fnRows] = await Promise.all([
|
|
438
|
+
run(RLS_SQL),
|
|
439
|
+
run(POLICIES_SQL),
|
|
440
|
+
run(GRANTS_SQL),
|
|
441
|
+
run(COLUMN_GRANTS_SQL),
|
|
442
|
+
run(functionsSql),
|
|
443
|
+
]);
|
|
444
|
+
return assembleContract({
|
|
445
|
+
rls: rls as RlsRow[],
|
|
446
|
+
policies: policies as PolicyRow[],
|
|
447
|
+
grants: grants as GrantRow[],
|
|
448
|
+
columnGrants: columnGrants as ColumnGrantRow[],
|
|
449
|
+
functions: (fnRows as any[]).map(mapFunctionRow),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Diff — declared (committed contract) vs live (introspected). Strict text compare.
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
export type DriftSeverity = 'drift';
|
|
458
|
+
|
|
459
|
+
export interface DriftFinding {
|
|
460
|
+
/** Schema-qualified table, or `fn:<name>` for a function finding. */
|
|
461
|
+
subject: string;
|
|
462
|
+
/** What changed: rls | grant | policy | table | function. */
|
|
463
|
+
kind: 'rls' | 'grant' | 'policy' | 'table' | 'function';
|
|
464
|
+
detail: string;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
468
|
+
return new Map(c.tables.map((t) => [t.table, t]));
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Compare the declared contract (committed source of truth) against the live database.
|
|
473
|
+
* Strict: predicate text is compared verbatim — the Postgres deparser is deterministic,
|
|
474
|
+
* so a re-pull of an unchanged policy is identical and any difference is a real change.
|
|
475
|
+
* Returns every discrepancy; an empty list means the live DB matches the declaration.
|
|
476
|
+
*/
|
|
477
|
+
export function diffContracts(declared: AuthzContract, live: AuthzContract): DriftFinding[] {
|
|
478
|
+
const findings: DriftFinding[] = [];
|
|
479
|
+
const dTables = tableMap(declared);
|
|
480
|
+
const lTables = tableMap(live);
|
|
481
|
+
|
|
482
|
+
for (const [name, d] of dTables) {
|
|
483
|
+
const l = lTables.get(name);
|
|
484
|
+
if (!l) {
|
|
485
|
+
findings.push({ subject: name, kind: 'table', detail: 'declared table is missing from the live database' });
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (d.rls.enabled !== l.rls.enabled) {
|
|
489
|
+
findings.push({ subject: name, kind: 'rls', detail: `RLS enabled: declared ${d.rls.enabled}, live ${l.rls.enabled}` });
|
|
490
|
+
}
|
|
491
|
+
if (d.rls.forced !== l.rls.forced) {
|
|
492
|
+
findings.push({ subject: name, kind: 'rls', detail: `RLS forced: declared ${d.rls.forced}, live ${l.rls.forced}` });
|
|
493
|
+
}
|
|
494
|
+
diffGrants(name, d, l, findings);
|
|
495
|
+
diffColumnGrants(name, d, l, findings);
|
|
496
|
+
diffPolicies(name, d, l, findings);
|
|
497
|
+
}
|
|
498
|
+
for (const name of lTables.keys()) {
|
|
499
|
+
if (!dTables.has(name)) {
|
|
500
|
+
findings.push({ subject: name, kind: 'table', detail: 'live table is not in the declared contract (undeclared table)' });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const dFns = new Map(declared.functions.map((f) => [f.name, f]));
|
|
505
|
+
const lFns = new Map(live.functions.map((f) => [f.name, f]));
|
|
506
|
+
for (const [name, d] of dFns) {
|
|
507
|
+
const l = lFns.get(name);
|
|
508
|
+
if (!l) {
|
|
509
|
+
findings.push({ subject: `fn:${name}`, kind: 'function', detail: 'declared SECDEF function is missing from the live database' });
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (d.hasSearchPath !== l.hasSearchPath) {
|
|
513
|
+
findings.push({ subject: `fn:${name}`, kind: 'function', detail: `pinned search_path: declared ${d.hasSearchPath}, live ${l.hasSearchPath}` });
|
|
514
|
+
}
|
|
515
|
+
if (d.ownerBypassesRls !== undefined && l.ownerBypassesRls !== undefined && d.ownerBypassesRls !== l.ownerBypassesRls) {
|
|
516
|
+
findings.push({ subject: `fn:${name}`, kind: 'function', detail: `owner bypasses RLS: declared ${d.ownerBypassesRls}, live ${l.ownerBypassesRls}` });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
for (const name of lFns.keys()) {
|
|
520
|
+
if (!dFns.has(name)) {
|
|
521
|
+
findings.push({ subject: `fn:${name}`, kind: 'function', detail: 'live SECDEF function is not in the declared contract' });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return findings;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
|
|
529
|
+
const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l.grants)]);
|
|
530
|
+
for (const g of grantees) {
|
|
531
|
+
const dp = (d.grants[g] ?? []).join(',');
|
|
532
|
+
const lp = (l.grants[g] ?? []).join(',');
|
|
533
|
+
if (dp !== lp) {
|
|
534
|
+
out.push({ subject: table, kind: 'grant', detail: `grant to ${g}: declared [${dp}], live [${lp}]` });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Flatten columnGrants to `grantee/PRIV -> sorted column csv` for a stable comparison. */
|
|
540
|
+
function flattenColumnGrants(t: TableContract): Map<string, string> {
|
|
541
|
+
const flat = new Map<string, string>();
|
|
542
|
+
for (const [grantee, byPriv] of Object.entries(t.columnGrants ?? {})) {
|
|
543
|
+
for (const [priv, cols] of Object.entries(byPriv)) {
|
|
544
|
+
flat.set(`${grantee}/${priv}`, [...cols].sort().join(','));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return flat;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function diffColumnGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
|
|
551
|
+
const dc = flattenColumnGrants(d);
|
|
552
|
+
const lc = flattenColumnGrants(l);
|
|
553
|
+
for (const key of new Set([...dc.keys(), ...lc.keys()])) {
|
|
554
|
+
const dCols = dc.get(key) ?? '';
|
|
555
|
+
const lCols = lc.get(key) ?? '';
|
|
556
|
+
if (dCols !== lCols) {
|
|
557
|
+
out.push({ subject: table, kind: 'grant', detail: `column grant ${key}: declared (${dCols}), live (${lCols})` });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function diffPolicies(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
|
|
563
|
+
const dPol = new Map(d.policies.map((p) => [p.name, p]));
|
|
564
|
+
const lPol = new Map(l.policies.map((p) => [p.name, p]));
|
|
565
|
+
for (const [name, dp] of dPol) {
|
|
566
|
+
const lp = lPol.get(name);
|
|
567
|
+
if (!lp) {
|
|
568
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${name}" declared but missing from the live database` });
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const changes: string[] = [];
|
|
572
|
+
if (dp.command !== lp.command) changes.push(`command ${dp.command}→${lp.command}`);
|
|
573
|
+
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
|
|
574
|
+
if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}]→[${lp.roles}]`);
|
|
575
|
+
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
|
|
576
|
+
if ((dp.check ?? '') !== (lp.check ?? '')) changes.push(`WITH CHECK changed`);
|
|
577
|
+
if (changes.length) {
|
|
578
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
for (const name of lPol.keys()) {
|
|
582
|
+
if (!dPol.has(name)) {
|
|
583
|
+
out.push({ subject: table, kind: 'policy', detail: `policy "${name}" exists live but is not declared (undeclared policy)` });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Re-export so a future assembler treats RLS flags the same coercion way as the rest. */
|
|
589
|
+
export { truthy as coerceBool };
|