@everystack/cli 0.4.40 → 0.4.43
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/package.json +2 -2
- package/src/cli/apply-execute.ts +20 -1
- package/src/cli/authz-baseline.ts +227 -0
- package/src/cli/authz-compile.ts +46 -4
- package/src/cli/authz-contract.ts +48 -1
- package/src/cli/authz-reconcile.ts +131 -22
- package/src/cli/authz-redteam.ts +32 -10
- package/src/cli/commands/db-apply.ts +18 -0
- package/src/cli/commands/db-authz.ts +17 -1
- package/src/cli/commands/db-check.ts +45 -0
- package/src/cli/commands/db-generate.ts +29 -3
- package/src/cli/commands/db-plan.ts +54 -0
- package/src/cli/commands/db-pull.ts +40 -0
- package/src/cli/commands/db-sync.ts +4 -0
- package/src/cli/declared-derived.ts +6 -1
- package/src/cli/edge-plan.ts +84 -6
- package/src/cli/migration-generate.ts +12 -2
- package/src/cli/model-render.ts +55 -20
- package/src/cli/schema-compile.ts +25 -2
- package/src/cli/schema-diff.ts +26 -1
- package/src/cli/state-apply.ts +127 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.43",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"structured-headers": "1.0.1",
|
|
110
110
|
"tsx": "4.21.0",
|
|
111
111
|
"typescript": "5.9.3",
|
|
112
|
-
"@everystack/model": "0.4.
|
|
112
|
+
"@everystack/model": "0.4.8"
|
|
113
113
|
},
|
|
114
114
|
"peerDependencies": {
|
|
115
115
|
"@everystack/server": ">=0.4.0",
|
package/src/cli/apply-execute.ts
CHANGED
|
@@ -70,12 +70,20 @@ export interface ApplyPlanOptions {
|
|
|
70
70
|
* attest their own --snapshot-ref keep the ceremony the command shell enforces).
|
|
71
71
|
*/
|
|
72
72
|
verifySnapshot?: (ctx: { planFrom: string }) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
73
|
+
/**
|
|
74
|
+
* The ungoverned-grantee gate, re-verified AT APPLY (B4). db:plan already refuses to mint
|
|
75
|
+
* against a stage holding a foreign grantee the baseline does not record — but a plan is
|
|
76
|
+
* an artifact with a lifetime, and a role minted between minting and applying would
|
|
77
|
+
* otherwise ride through unnoticed. Same contract as the other gates: refusal recorded in
|
|
78
|
+
* schema_log. Omitted = no gate (callers with no baseline to check against).
|
|
79
|
+
*/
|
|
80
|
+
verifyAuthzBaseline?: (ctx: { contract: AuthzContract }) => Promise<{ ok: true } | { ok: false; reason: string }>;
|
|
73
81
|
/** Injectable clock for tests. */
|
|
74
82
|
now?: () => number;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
export interface ApplyPlanResult {
|
|
78
|
-
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'verify-failed';
|
|
86
|
+
status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'snapshot-refused' | 'authz-refused' | 'verify-failed';
|
|
79
87
|
liveFingerprint: string;
|
|
80
88
|
reason?: string;
|
|
81
89
|
logId?: number;
|
|
@@ -157,6 +165,17 @@ export async function executeApplyPlan(
|
|
|
157
165
|
}
|
|
158
166
|
}
|
|
159
167
|
|
|
168
|
+
// Before ANY write: a foreign grantee that arrived after the plan was minted must stop
|
|
169
|
+
// the apply, not ride through it. The plan's own gates check the SCHEMA has not moved;
|
|
170
|
+
// this checks who can reach it.
|
|
171
|
+
if (options.verifyAuthzBaseline) {
|
|
172
|
+
const authz = await options.verifyAuthzBaseline({ contract: beforeAuthz });
|
|
173
|
+
if (!authz.ok) {
|
|
174
|
+
await recordRefusal(runner, plan, live, 'ungoverned grantee', authz.reason, options);
|
|
175
|
+
return { status: 'authz-refused', liveFingerprint: live, reason: authz.reason };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
160
179
|
if (plan.destructive > 0 && options.verifyAuthority) {
|
|
161
180
|
const authority = await options.verifyAuthority();
|
|
162
181
|
if (!authority.ok) {
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* authz-baseline — the foreign grantees that were already there, and the gate that makes a
|
|
3
|
+
* NEW one fail.
|
|
4
|
+
*
|
|
5
|
+
* The reconciler exempts a grantee the models do not govern rather than revoking it
|
|
6
|
+
* (authz-reconcile's `governedRoleSet` — a migrator or BI role must not have its access
|
|
7
|
+
* destroyed by a plan nobody read). That exemption is only half a design. Without the other
|
|
8
|
+
* half, an attacker-minted role holding SELECT on every table is reported forever and fails
|
|
9
|
+
* nothing, which is the failure mode the exemption was rejected for in the first place.
|
|
10
|
+
*
|
|
11
|
+
* So: record the foreign grantees present AT ADOPTION, per stage. After that, an ungoverned
|
|
12
|
+
* grantee that is not in the stage's baseline — or a baselined one that has GROWN — refuses
|
|
13
|
+
* the plan. A greenfield app's baseline is empty, so any foreign grantee is a failure from
|
|
14
|
+
* day one; a brownfield app's migrator is in it, so adoption proceeds with a warning that
|
|
15
|
+
* never goes away.
|
|
16
|
+
*
|
|
17
|
+
* Two deliberate decisions, both adjudicated:
|
|
18
|
+
*
|
|
19
|
+
* - The baseline lives in the REPO, not the database. Storing it in the database looks
|
|
20
|
+
* tamper-evident and is not: the attacker who minted the role owns the database and can
|
|
21
|
+
* write the baseline row in the same session. More importantly it would hide the
|
|
22
|
+
* exemption from the one human checkpoint that exists — a pull request. Adding a
|
|
23
|
+
* grantee is a diff line someone merges. The database keeps a `schema_log` memoir of
|
|
24
|
+
* the adoption event so a reviewer can check the claim against the observation.
|
|
25
|
+
* - It is FROZEN at adoption. There is no "it showed up in production, baseline it"
|
|
26
|
+
* path — that is the whole gate. Admitting a new role costs a PR line, on purpose.
|
|
27
|
+
*
|
|
28
|
+
* Per-stage, because stages legitimately differ (a BI role in production and nowhere else) —
|
|
29
|
+
* and scoped, so a production entry excuses that grantee in production only. The same role
|
|
30
|
+
* appearing in dev still fails there.
|
|
31
|
+
*
|
|
32
|
+
* Pure, with one exception named at the bottom: `readBaselineFile`, which every gate needs
|
|
33
|
+
* to agree on (including "the file is absent", which must behave exactly like an empty
|
|
34
|
+
* baseline rather than like a skipped check).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import fs from 'node:fs/promises';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import type { GrantExemption } from './authz-reconcile.js';
|
|
40
|
+
|
|
41
|
+
/** The repo-relative artifact. Generated — regenerating it must reproduce it byte for byte. */
|
|
42
|
+
export const BASELINE_FILE = 'db/authz-baseline.json';
|
|
43
|
+
|
|
44
|
+
/** Privileges that change data or structure — flagged loudly, because a reviewer skims. */
|
|
45
|
+
const WRITE_PRIVILEGES = new Set(['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER']);
|
|
46
|
+
|
|
47
|
+
export interface BaselineGrantee {
|
|
48
|
+
/** The union of table-level privileges held at adoption, sorted. */
|
|
49
|
+
privileges: string[];
|
|
50
|
+
/** The tables it held them on, sorted. */
|
|
51
|
+
tables: string[];
|
|
52
|
+
/** True when any privilege writes. Surfaced so a reviewer sees it without reading the list. */
|
|
53
|
+
write: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BaselineStage {
|
|
57
|
+
/** When the observation was taken — provenance for the claim, not used by the gate. */
|
|
58
|
+
observedAt: string;
|
|
59
|
+
/** The live base fingerprint at observation time — what the claim was true OF. */
|
|
60
|
+
fingerprint: string;
|
|
61
|
+
grantees: Record<string, BaselineGrantee>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface AuthzBaseline {
|
|
65
|
+
version: 1;
|
|
66
|
+
stages: Record<string, BaselineStage>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const EMPTY_BASELINE: AuthzBaseline = { version: 1, stages: {} };
|
|
70
|
+
|
|
71
|
+
/** Fold the observed exemptions into one entry per grantee. */
|
|
72
|
+
export function buildStageBaseline(
|
|
73
|
+
exemptions: readonly GrantExemption[],
|
|
74
|
+
meta: { observedAt: string; fingerprint: string },
|
|
75
|
+
): BaselineStage {
|
|
76
|
+
const grantees: Record<string, BaselineGrantee> = {};
|
|
77
|
+
for (const e of exemptions) {
|
|
78
|
+
const g = (grantees[e.grantee] ??= { privileges: [], tables: [], write: false });
|
|
79
|
+
g.privileges = [...new Set([...g.privileges, ...e.privileges])].sort();
|
|
80
|
+
g.tables = [...new Set([...g.tables, e.table])].sort();
|
|
81
|
+
// A column-scoped privilege is still that privilege — it must count toward `write`,
|
|
82
|
+
// or a column-scoped UPDATE would read as a harmless reader in review.
|
|
83
|
+
const all = [...e.privileges, ...Object.keys(e.columnPrivileges ?? {})];
|
|
84
|
+
g.write ||= all.some((p) => WRITE_PRIVILEGES.has(p.toUpperCase()));
|
|
85
|
+
}
|
|
86
|
+
return { observedAt: meta.observedAt, fingerprint: meta.fingerprint, grantees };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Write one stage's entry into the file, leaving every other stage untouched. */
|
|
90
|
+
export function mergeBaseline(existing: AuthzBaseline | null, stage: string, entry: BaselineStage): AuthzBaseline {
|
|
91
|
+
return { version: 1, stages: { ...(existing?.stages ?? {}), [stage]: entry } };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The artifact's canonical text. Keys sorted at every level so regenerating an unchanged
|
|
96
|
+
* baseline is byte-identical — that equality IS the offline check, and a diff that reorders
|
|
97
|
+
* itself would make the review signal worthless.
|
|
98
|
+
*/
|
|
99
|
+
export function renderBaseline(baseline: AuthzBaseline): string {
|
|
100
|
+
const stages: Record<string, BaselineStage> = {};
|
|
101
|
+
for (const stage of Object.keys(baseline.stages).sort()) {
|
|
102
|
+
const s = baseline.stages[stage];
|
|
103
|
+
const grantees: Record<string, BaselineGrantee> = {};
|
|
104
|
+
for (const g of Object.keys(s.grantees).sort()) {
|
|
105
|
+
const { privileges, tables, write } = s.grantees[g];
|
|
106
|
+
grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
|
|
107
|
+
}
|
|
108
|
+
stages[stage] = { observedAt: s.observedAt, fingerprint: s.fingerprint, grantees };
|
|
109
|
+
}
|
|
110
|
+
return JSON.stringify({ version: 1, stages }, null, 2) + '\n';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Parse the artifact, refusing anything malformed by name — a baseline that silently reads
|
|
114
|
+
* as empty would disable the gate, which is the one outcome worse than failing. */
|
|
115
|
+
export function parseBaseline(text: string, where = BASELINE_FILE): AuthzBaseline {
|
|
116
|
+
let raw: unknown;
|
|
117
|
+
try {
|
|
118
|
+
raw = JSON.parse(text);
|
|
119
|
+
} catch (err: any) {
|
|
120
|
+
throw new Error(`${where} is not valid JSON: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
const b = raw as Partial<AuthzBaseline>;
|
|
123
|
+
if (b?.version !== 1) throw new Error(`${where}: unsupported version ${String(b?.version)} — expected 1.`);
|
|
124
|
+
if (b.stages == null || typeof b.stages !== 'object') throw new Error(`${where}: missing a "stages" object.`);
|
|
125
|
+
for (const [stage, s] of Object.entries(b.stages)) {
|
|
126
|
+
if (s?.grantees == null || typeof s.grantees !== 'object') {
|
|
127
|
+
throw new Error(`${where}: stage "${stage}" has no "grantees" object.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return b as AuthzBaseline;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** One reason a live grantee fails the gate. */
|
|
134
|
+
export interface BaselineViolation {
|
|
135
|
+
grantee: string;
|
|
136
|
+
kind: 'unbaselined' | 'widened';
|
|
137
|
+
detail: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The gate. A stage passes when every ungoverned grantee is in its baseline AND holds
|
|
142
|
+
* nothing beyond what the baseline recorded.
|
|
143
|
+
*
|
|
144
|
+
* `widened` matters as much as `unbaselined`: a role baselined with SELECT that has since
|
|
145
|
+
* acquired UPDATE, or spread to new tables, is not the role that was reviewed. Freezing the
|
|
146
|
+
* NAME while letting the privileges grow would leave the gate defending nothing.
|
|
147
|
+
*
|
|
148
|
+
* An ABSENT stage entry is not an empty one — it means the stage was never adopted, and any
|
|
149
|
+
* foreign grantee there fails. That is deliberate: the safe reading of "no record" is "not
|
|
150
|
+
* reviewed", never "nothing to see".
|
|
151
|
+
*/
|
|
152
|
+
export function checkAgainstBaseline(
|
|
153
|
+
baseline: AuthzBaseline | null,
|
|
154
|
+
stage: string,
|
|
155
|
+
exemptions: readonly GrantExemption[],
|
|
156
|
+
): BaselineViolation[] {
|
|
157
|
+
const observed = buildStageBaseline(exemptions, { observedAt: '', fingerprint: '' }).grantees;
|
|
158
|
+
const recorded = baseline?.stages[stage]?.grantees ?? {};
|
|
159
|
+
const violations: BaselineViolation[] = [];
|
|
160
|
+
|
|
161
|
+
for (const grantee of Object.keys(observed).sort()) {
|
|
162
|
+
const now = observed[grantee];
|
|
163
|
+
const then = recorded[grantee];
|
|
164
|
+
if (!then) {
|
|
165
|
+
violations.push({
|
|
166
|
+
grantee,
|
|
167
|
+
kind: 'unbaselined',
|
|
168
|
+
detail: `holds ${now.privileges.join(', ') || 'column-scoped privileges'} on ${now.tables.length} table(s) and is not in the ${stage} baseline`,
|
|
169
|
+
});
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const newPrivileges = now.privileges.filter((p) => !then.privileges.includes(p));
|
|
173
|
+
const newTables = now.tables.filter((t) => !then.tables.includes(t));
|
|
174
|
+
if (newPrivileges.length > 0 || newTables.length > 0) {
|
|
175
|
+
violations.push({
|
|
176
|
+
grantee,
|
|
177
|
+
kind: 'widened',
|
|
178
|
+
detail: [
|
|
179
|
+
newPrivileges.length ? `gained ${newPrivileges.join(', ')}` : null,
|
|
180
|
+
newTables.length ? `spread to ${newTables.join(', ')}` : null,
|
|
181
|
+
].filter(Boolean).join('; ') + ` since the ${stage} baseline`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return violations;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The refusal an operator reads. Names every violation — a count would not be actionable. */
|
|
189
|
+
export function baselineRefusal(stage: string, violations: readonly BaselineViolation[]): string {
|
|
190
|
+
const lines = violations.map((v) => ` - ${v.grantee} ${v.detail}`);
|
|
191
|
+
return [
|
|
192
|
+
`${violations.length} role(s) hold privileges on ${stage} that the models do not govern and the baseline does not record:`,
|
|
193
|
+
...lines,
|
|
194
|
+
'',
|
|
195
|
+
`A role the models cannot name is left alone by the reconciler, so it must be reviewed instead of reconciled.`,
|
|
196
|
+
`If these are legitimate, add them deliberately: re-run \`db:pull --abilities live --stage ${stage}\` to regenerate ${BASELINE_FILE},`,
|
|
197
|
+
`review the diff (write privileges are flagged), and commit it. If they are NOT legitimate, revoke them in the database first.`,
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Read the baseline artifact, or null when there is none.
|
|
203
|
+
*
|
|
204
|
+
* ABSENT is not an error and not a skip — it is an EMPTY baseline, so every foreign grantee
|
|
205
|
+
* fails the gate. That is the greenfield default and it is the safe direction: a project
|
|
206
|
+
* that never adopted a baseline has never reviewed a foreign role. A file that exists but
|
|
207
|
+
* cannot be parsed THROWS (see parseBaseline) — silently continuing past a corrupt baseline
|
|
208
|
+
* would disable the gate exactly when someone has been editing it.
|
|
209
|
+
*/
|
|
210
|
+
export async function readBaselineFile(cwd = process.cwd()): Promise<AuthzBaseline | null> {
|
|
211
|
+
const file = path.resolve(cwd, BASELINE_FILE);
|
|
212
|
+
let text: string;
|
|
213
|
+
try {
|
|
214
|
+
text = await fs.readFile(file, 'utf8');
|
|
215
|
+
} catch {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
return parseBaseline(text, BASELINE_FILE);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Write the artifact, creating `db/` if this is the first adoption. */
|
|
222
|
+
export async function writeBaselineFile(baseline: AuthzBaseline, cwd = process.cwd()): Promise<string> {
|
|
223
|
+
const file = path.resolve(cwd, BASELINE_FILE);
|
|
224
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
225
|
+
await fs.writeFile(file, renderBaseline(baseline), 'utf8');
|
|
226
|
+
return file;
|
|
227
|
+
}
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import type { ModelDescriptor, Ability } from '@everystack/model';
|
|
24
24
|
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
|
|
25
|
+
import { parenthesizeOnce } from './authz-contract.js';
|
|
25
26
|
|
|
26
27
|
export interface CompileOptions {
|
|
27
28
|
/** Schema the table lives in. Default: `public`. */
|
|
@@ -176,9 +177,17 @@ function resolveVia(model: ModelDescriptor): ViaRef | null {
|
|
|
176
177
|
};
|
|
177
178
|
}
|
|
178
179
|
|
|
179
|
-
/**
|
|
180
|
+
/**
|
|
181
|
+
* The soft-delete column SQL name, when the model DECLARES one.
|
|
182
|
+
*
|
|
183
|
+
* Keyed on `softDelete`, never on the field's presence. A column named `deleted_at` is not a
|
|
184
|
+
* statement of visibility intent, and inferring the guard from it made the compiler author a
|
|
185
|
+
* security predicate nobody wrote — one that `db:pull` then handed to every brownfield model
|
|
186
|
+
* automatically, so the first plan narrowed a policy the adopter had written. `defineModel`
|
|
187
|
+
* refuses to guess: a model with the field and a public read must say which it means.
|
|
188
|
+
*/
|
|
180
189
|
function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
181
|
-
return
|
|
190
|
+
return model.softDelete ? 'deleted_at' : null;
|
|
182
191
|
}
|
|
183
192
|
|
|
184
193
|
/**
|
|
@@ -228,7 +237,11 @@ function rawPredicate(value: unknown, where: string): string | null {
|
|
|
228
237
|
`${where}: expected a sql\`…\` fragment (import { sql } from '@everystack/model'), got ${typeof value}. A bare string is rejected on purpose — an RLS predicate is authored, never stringly assembled.`,
|
|
229
238
|
);
|
|
230
239
|
}
|
|
231
|
-
|
|
240
|
+
// Parenthesized exactly once. A pulled predicate arrives already fully parenthesized
|
|
241
|
+
// (that is how pg_get_expr deparses it), and wrapping it again made the compiled policy
|
|
242
|
+
// differ from the live one by a single layer of parens — enough for the reconciler to
|
|
243
|
+
// plan a DROP + CREATE that changed nothing.
|
|
244
|
+
return parenthesizeOnce(text.trim());
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
/** AND-compose predicates, dropping the vacuous ones. A condition only ever NARROWS. */
|
|
@@ -266,11 +279,20 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
266
279
|
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
267
280
|
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
268
281
|
|
|
282
|
+
/**
|
|
283
|
+
* A read scoped to a named role — `can('read', { role })`. It compiles to its own
|
|
284
|
+
* `<table>_select_<role>` policy, so its predicate must NOT also be folded into the
|
|
285
|
+
* public read's predicate: a rule written for one role would otherwise narrow what
|
|
286
|
+
* every other role can see.
|
|
287
|
+
*/
|
|
288
|
+
const isRoleRead = (a: Ability): boolean =>
|
|
289
|
+
a.action === 'read' && Boolean(a.condition.role) && !isColumnRead(a);
|
|
290
|
+
|
|
269
291
|
// Author-supplied predicates, per action. `manage` carries no predicate of its own —
|
|
270
292
|
// it is the admin bypass — so only the specific verbs are consulted.
|
|
271
293
|
const predFor = (action: Ability['action']): string | null => {
|
|
272
294
|
for (const a of model.abilities) {
|
|
273
|
-
if (a.action !== action) continue;
|
|
295
|
+
if (a.action !== action || isRoleRead(a)) continue;
|
|
274
296
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
|
|
275
297
|
if (p) return p;
|
|
276
298
|
}
|
|
@@ -336,6 +358,26 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
336
358
|
policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
|
|
337
359
|
}
|
|
338
360
|
|
|
361
|
+
// Role-scoped reads — `can('read', { role })`, with or without a predicate. These match
|
|
362
|
+
// none of the branches above: the public read requires NO role, and the owner/column reads
|
|
363
|
+
// require a row-scoping condition. Before this they compiled to a SELECT grant and no
|
|
364
|
+
// policy at all — and since `rls.enabled` is unconditional, the role saw ZERO rows while
|
|
365
|
+
// the model read as though it could see its own. The declared predicate was computed and
|
|
366
|
+
// discarded. That is the same failure `rawPredicate` throws over, one level up: a predicate
|
|
367
|
+
// that evaporates is an authorization hole wearing the costume of a working rule.
|
|
368
|
+
for (const a of abilities.filter(isRoleRead)) {
|
|
369
|
+
const role = a.condition.role!;
|
|
370
|
+
const name = `${t}_select_${role}`;
|
|
371
|
+
const where = `${table}: can('read', { role: '${role}' })`;
|
|
372
|
+
if (policies.some((p) => p.name === name)) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`${where} collides with the ${name} policy already compiled from this model's public read. `
|
|
375
|
+
+ `Declare one or the other: permissive policies OR together, so the role-scoped rule could only ever widen — never the narrowing it reads as.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
policy(name, 'SELECT', [role], rawPredicate(a.condition.sql ?? a.condition.where, where) ?? 'true', null);
|
|
379
|
+
}
|
|
380
|
+
|
|
339
381
|
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
340
382
|
// UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
|
|
341
383
|
//
|
|
@@ -82,6 +82,51 @@ export interface PolicyContract {
|
|
|
82
82
|
check: string | null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The WITH CHECK expression PostgreSQL will actually enforce.
|
|
87
|
+
*
|
|
88
|
+
* For `ALL` and `UPDATE`, an omitted `WITH CHECK` is not "no check" — the server reuses the
|
|
89
|
+
* `USING` expression. So a live `FOR ALL USING (true)` (which is what `CREATE POLICY … USING
|
|
90
|
+
* (true)` records, with `pg_policies.with_check` NULL) and a compiled `FOR ALL USING (true)
|
|
91
|
+
* WITH CHECK (true)` are the SAME authorization. Comparing the raw fields calls them
|
|
92
|
+
* different and plans a DROP + CREATE — the loudest possible way to say "no change", and it
|
|
93
|
+
* defeats a zero-statement bar even when the SQL is identical.
|
|
94
|
+
*
|
|
95
|
+
* `INSERT` has only a check, `SELECT`/`DELETE` have none — for those the field stands alone
|
|
96
|
+
* and no defaulting applies.
|
|
97
|
+
*/
|
|
98
|
+
export function effectivePolicyCheck(p: PolicyContract): string | null {
|
|
99
|
+
if (p.command === 'ALL' || p.command === 'UPDATE') return p.check ?? p.using;
|
|
100
|
+
return p.check;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when `s`'s outer parens already wrap the whole expression (redundant to add more). */
|
|
104
|
+
export function isWrappedExpression(s: string): boolean {
|
|
105
|
+
if (!s.startsWith('(') || !s.endsWith(')')) return false;
|
|
106
|
+
let depth = 0;
|
|
107
|
+
for (let i = 0; i < s.length; i++) {
|
|
108
|
+
if (s[i] === '(') depth++;
|
|
109
|
+
else if (s[i] === ')') {
|
|
110
|
+
depth--;
|
|
111
|
+
if (depth === 0 && i < s.length - 1) return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return depth === 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A predicate parenthesized exactly once — the normal form both producers must agree on.
|
|
119
|
+
*
|
|
120
|
+
* `pg_get_expr` already deparses a policy predicate fully parenthesized, so `db:pull` renders
|
|
121
|
+
* one into a model verbatim. Wrapping it again produced a compiled predicate identical to
|
|
122
|
+
* live but for one layer of parens, which the equality check called a difference and turned
|
|
123
|
+
* into a same-name DROP + CREATE that changed nothing. Emission already knew this rule; the
|
|
124
|
+
* comparison did not, so both go through this.
|
|
125
|
+
*/
|
|
126
|
+
export function parenthesizeOnce(expr: string): string {
|
|
127
|
+
return isWrappedExpression(expr) ? expr : `(${expr})`;
|
|
128
|
+
}
|
|
129
|
+
|
|
85
130
|
/**
|
|
86
131
|
* One function, enumerated. The cross-table authorization that RLS does not hold lives
|
|
87
132
|
* in SECURITY DEFINER functions as imperative gates; the contract records that they
|
|
@@ -581,7 +626,9 @@ function diffPolicies(table: string, d: TableContract, l: TableContract, out: Dr
|
|
|
581
626
|
if (dp.permissive !== lp.permissive) changes.push(`permissive ${dp.permissive}→${lp.permissive}`);
|
|
582
627
|
if (dp.roles.join(',') !== lp.roles.join(',')) changes.push(`roles [${dp.roles}]→[${lp.roles}]`);
|
|
583
628
|
if ((dp.using ?? '') !== (lp.using ?? '')) changes.push(`USING changed`);
|
|
584
|
-
|
|
629
|
+
// Compared through the server's own defaulting rule, so this agrees with
|
|
630
|
+
// emitReconcileSql — otherwise db:check reports drift the plan does not carry.
|
|
631
|
+
if ((effectivePolicyCheck(dp) ?? '') !== (effectivePolicyCheck(lp) ?? '')) changes.push(`WITH CHECK changed`);
|
|
585
632
|
if (changes.length) {
|
|
586
633
|
out.push({ subject: table, kind: 'policy', detail: `policy "${name}": ${changes.join(', ')}` });
|
|
587
634
|
}
|
|
@@ -16,27 +16,110 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
|
|
19
|
+
import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
|
|
19
20
|
import { quoteQualified } from './pg-ident.js';
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Always governed, whatever the models say. `PUBLIC` because a grant to PUBLIC is the
|
|
24
|
+
* broadest privilege the database can express and must never be exemptible; the three
|
|
25
|
+
* vocabulary roles because they ARE the model's authz surface — letting a module drop one
|
|
26
|
+
* would be a way to hide a grant from the thing that audits it.
|
|
27
|
+
*/
|
|
28
|
+
export const ALWAYS_GOVERNED = ['PUBLIC', 'admin', 'anon', 'authenticated'] as const;
|
|
29
|
+
|
|
30
|
+
/** One grantee the declared contract does not govern, with what it actually holds. */
|
|
31
|
+
export interface GrantExemption {
|
|
32
|
+
grantee: string;
|
|
33
|
+
table: string;
|
|
34
|
+
/** Table-level privileges. */
|
|
35
|
+
privileges: string[];
|
|
36
|
+
/** Column-scoped privileges, `privilege → columns`. Present only when there are any. */
|
|
37
|
+
columnPrivileges?: Record<string, string[]>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The roles the declared authz governs: everything the models name, plus the always-governed
|
|
42
|
+
* set, plus whatever the modules widen it with. WIDEN-ONLY by construction — `declared` and
|
|
43
|
+
* {@link ALWAYS_GOVERNED} go in unconditionally, so `extra` can add and can never subtract.
|
|
44
|
+
*
|
|
45
|
+
* Grantee comparison is case-insensitive on `PUBLIC` only (PostgreSQL's pseudo-role, which
|
|
46
|
+
* the catalog reports uppercase and the models spell either way); real role names are
|
|
47
|
+
* case-sensitive because PostgreSQL's are.
|
|
48
|
+
*/
|
|
49
|
+
export function governedRoleSet(declared: AuthzContract, extra: readonly string[] = []): Set<string> {
|
|
50
|
+
const governed = new Set<string>(ALWAYS_GOVERNED);
|
|
51
|
+
for (const t of declared.tables) {
|
|
52
|
+
for (const grantee of Object.keys(t.grants)) governed.add(grantee);
|
|
53
|
+
for (const grantee of Object.keys(t.columnGrants ?? {})) governed.add(grantee);
|
|
54
|
+
}
|
|
55
|
+
for (const role of extra) governed.add(role);
|
|
56
|
+
return governed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isGoverned = (governed: ReadonlySet<string>, grantee: string): boolean =>
|
|
60
|
+
governed.has(grantee) || (grantee.toUpperCase() === 'PUBLIC' && governed.has('PUBLIC'));
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Every live grantee the declared contract does not govern, with its exact privileges —
|
|
64
|
+
* the enumeration that makes exempting them acceptable at all.
|
|
65
|
+
*
|
|
66
|
+
* The reconciler leaves these alone (a live migrator/ETL/BI role the model vocabulary
|
|
67
|
+
* cannot name must not have its access destroyed by a plan nobody read). That is only
|
|
68
|
+
* defensible because every artifact claiming to describe authz state reprints this list:
|
|
69
|
+
* exempt from reconciliation, never exempt from the report. Returns `grantee → table →
|
|
70
|
+
* privileges`, sorted, because a COUNT is not auditable.
|
|
71
|
+
*/
|
|
72
|
+
export function ungovernedGrants(live: AuthzContract, governed: ReadonlySet<string>): GrantExemption[] {
|
|
73
|
+
const out: GrantExemption[] = [];
|
|
74
|
+
for (const t of live.tables) {
|
|
75
|
+
const grantees = new Set([...Object.keys(t.grants), ...Object.keys(t.columnGrants ?? {})]);
|
|
76
|
+
for (const grantee of [...grantees].sort()) {
|
|
77
|
+
if (isGoverned(governed, grantee)) continue;
|
|
78
|
+
const columnPrivileges = t.columnGrants?.[grantee];
|
|
79
|
+
out.push({
|
|
80
|
+
grantee,
|
|
81
|
+
table: t.table,
|
|
82
|
+
privileges: [...(t.grants[grantee] ?? [])].sort(),
|
|
83
|
+
...(columnPrivileges && Object.keys(columnPrivileges).length > 0 ? { columnPrivileges } : {}),
|
|
84
|
+
});
|
|
30
85
|
}
|
|
31
86
|
}
|
|
32
|
-
return
|
|
87
|
+
return out.sort((a, b) => a.grantee.localeCompare(b.grantee) || a.table.localeCompare(b.table));
|
|
33
88
|
}
|
|
34
89
|
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
90
|
+
/**
|
|
91
|
+
* The exemption report, one line per grantee — the shared rendering every surface prints.
|
|
92
|
+
*
|
|
93
|
+
* Grouped by grantee (an operator asks "what does this role have?", not "what does this
|
|
94
|
+
* table give?") and always EXACT: role, privileges, and the tables. `3 ungoverned roles`
|
|
95
|
+
* would not be auditable, which is the whole reason the exemption is allowed to exist.
|
|
96
|
+
*/
|
|
97
|
+
export function renderGrantExemptions(exemptions: readonly GrantExemption[]): string[] {
|
|
98
|
+
const byGrantee = new Map<string, GrantExemption[]>();
|
|
99
|
+
for (const e of exemptions) byGrantee.set(e.grantee, [...(byGrantee.get(e.grantee) ?? []), e]);
|
|
100
|
+
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
for (const [grantee, entries] of [...byGrantee.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
103
|
+
// The privilege set is usually uniform across the tables; say it once and list the
|
|
104
|
+
// tables, and only fall back to per-table lines when it genuinely differs.
|
|
105
|
+
const shapes = new Set(entries.map((e) => e.privileges.join(',')));
|
|
106
|
+
if (shapes.size === 1 && entries.length > 1) {
|
|
107
|
+
lines.push(`${grantee}: ${entries[0].privileges.join(', ')} on ${entries.length} table(s) — ${entries.map((e) => e.table).sort().join(', ')}`);
|
|
108
|
+
} else {
|
|
109
|
+
for (const e of entries.sort((a, b) => a.table.localeCompare(b.table))) {
|
|
110
|
+
const cols = e.columnPrivileges
|
|
111
|
+
? `; column-scoped ${Object.entries(e.columnPrivileges).map(([p, c]) => `${p} (${c.join(', ')})`).join(', ')}`
|
|
112
|
+
: '';
|
|
113
|
+
lines.push(`${grantee}: ${e.privileges.join(', ') || 'no table-level privileges'} on ${e.table}${cols}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return lines;
|
|
38
118
|
}
|
|
39
119
|
|
|
120
|
+
/** A USING/WITH CHECK clause expression, parenthesized exactly once. */
|
|
121
|
+
const clause = parenthesizeOnce;
|
|
122
|
+
|
|
40
123
|
/** A CREATE POLICY statement from a policy descriptor. */
|
|
41
124
|
function policyCreateSql(table: string, p: PolicyContract): string {
|
|
42
125
|
const parts = [`CREATE POLICY ${p.name} ON ${table}`];
|
|
@@ -52,13 +135,19 @@ function policyDropSql(table: string, name: string): string {
|
|
|
52
135
|
return `DROP POLICY IF EXISTS ${name} ON ${table};`;
|
|
53
136
|
}
|
|
54
137
|
|
|
55
|
-
/**
|
|
138
|
+
/**
|
|
139
|
+
* Two policies are the same authorization when every diffed field matches. The check is
|
|
140
|
+
* compared through {@link effectivePolicyCheck}, i.e. the server's own defaulting rule —
|
|
141
|
+
* a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
|
|
142
|
+
* authorize identically, and reconciling them would emit a DROP + CREATE that changes
|
|
143
|
+
* nothing.
|
|
144
|
+
*/
|
|
56
145
|
function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
|
|
57
146
|
return a.command === b.command
|
|
58
147
|
&& a.permissive === b.permissive
|
|
59
148
|
&& a.roles.join(',') === b.roles.join(',')
|
|
60
149
|
&& (a.using ?? '') === (b.using ?? '')
|
|
61
|
-
&& (a
|
|
150
|
+
&& (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
|
|
62
151
|
}
|
|
63
152
|
|
|
64
153
|
function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
@@ -72,9 +161,17 @@ function tableMap(c: AuthzContract): Map<string, TableContract> {
|
|
|
72
161
|
* revoke/grant — so a replaced policy never briefly co-exists with its old form
|
|
73
162
|
* and policies exist before grants reference the table.
|
|
74
163
|
*/
|
|
75
|
-
export function emitReconcileSql(
|
|
164
|
+
export function emitReconcileSql(
|
|
165
|
+
declared: AuthzContract,
|
|
166
|
+
live: AuthzContract,
|
|
167
|
+
opts: { governedRoles?: ReadonlySet<string> } = {},
|
|
168
|
+
): string[] {
|
|
76
169
|
const sql: string[] = [];
|
|
77
170
|
const lTables = tableMap(live);
|
|
171
|
+
// Default: govern exactly what the models name (plus the always-governed set). A caller
|
|
172
|
+
// that passes nothing gets the safe-for-greenfield behaviour; a brownfield caller widens
|
|
173
|
+
// it with the modules' declared governedRoles.
|
|
174
|
+
const governed = opts.governedRoles ?? governedRoleSet(declared);
|
|
78
175
|
|
|
79
176
|
for (const d of declared.tables) {
|
|
80
177
|
const l = lTables.get(d.table);
|
|
@@ -83,8 +180,8 @@ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract):
|
|
|
83
180
|
const table = quoteQualified(d.table);
|
|
84
181
|
reconcileRls(table, d, l, sql);
|
|
85
182
|
reconcilePolicies(table, d, l, sql);
|
|
86
|
-
reconcileGrants(table, d, l, sql);
|
|
87
|
-
reconcileColumnGrants(table, d, l, sql);
|
|
183
|
+
reconcileGrants(table, d, l, sql, governed);
|
|
184
|
+
reconcileColumnGrants(table, d, l, sql, governed);
|
|
88
185
|
}
|
|
89
186
|
|
|
90
187
|
return sql;
|
|
@@ -108,6 +205,7 @@ export function emitReconcileSql(declared: AuthzContract, live: AuthzContract):
|
|
|
108
205
|
*/
|
|
109
206
|
export function emitSwapAuthzSql(declared: AuthzContract): string[] {
|
|
110
207
|
const sql: string[] = [];
|
|
208
|
+
const governed = governedRoleSet(declared);
|
|
111
209
|
for (const d of declared.tables) {
|
|
112
210
|
const table = quoteQualified(d.table);
|
|
113
211
|
if (d.rls.enabled) sql.push(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
|
|
@@ -116,8 +214,10 @@ export function emitSwapAuthzSql(declared: AuthzContract): string[] {
|
|
|
116
214
|
sql.push(policyDropSql(table, p.name)); // idempotent against the policy the dump restored
|
|
117
215
|
sql.push(policyCreateSql(table, p));
|
|
118
216
|
}
|
|
119
|
-
|
|
120
|
-
|
|
217
|
+
// No live side here, so every grantee comes from the DECLARED contract and is
|
|
218
|
+
// governed by definition — the exemption can never fire on this path.
|
|
219
|
+
reconcileGrants(table, d, undefined, sql, governed);
|
|
220
|
+
reconcileColumnGrants(table, d, undefined, sql, governed);
|
|
121
221
|
}
|
|
122
222
|
return sql;
|
|
123
223
|
}
|
|
@@ -145,9 +245,15 @@ function reconcilePolicies(table: string, d: TableContract, l: TableContract | u
|
|
|
145
245
|
}
|
|
146
246
|
}
|
|
147
247
|
|
|
148
|
-
function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
248
|
+
function reconcileGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
|
|
149
249
|
const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l?.grants ?? {})]);
|
|
150
250
|
for (const grantee of [...grantees].sort()) {
|
|
251
|
+
// A live grantee the declared authz does not govern is LEFT ALONE. The union below
|
|
252
|
+
// would otherwise revoke every privilege it holds, because "no declaration" and "no
|
|
253
|
+
// privileges" are the same thing to a set difference — which is how a plan came to
|
|
254
|
+
// carry 32 revokes against a brownfield adopter's migration role. It is reported
|
|
255
|
+
// instead, by ungovernedGrants(), on every surface that describes authz state.
|
|
256
|
+
if (!isGoverned(governed, grantee)) continue;
|
|
151
257
|
const declared = new Set(d.grants[grantee] ?? []);
|
|
152
258
|
const live = new Set(l?.grants?.[grantee] ?? []);
|
|
153
259
|
const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
|
|
@@ -165,10 +271,13 @@ function reconcileGrants(table: string, d: TableContract, l: TableContract | und
|
|
|
165
271
|
* those live-but-not-declared. This is how `auth.users` exposes id/email/role (and only those)
|
|
166
272
|
* to `authenticated` without a whole-table grant. Columns are sorted, so a re-pull is a no-op.
|
|
167
273
|
*/
|
|
168
|
-
function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
|
|
274
|
+
function reconcileColumnGrants(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
|
|
169
275
|
const dcg = d.columnGrants ?? {};
|
|
170
276
|
const lcg = l?.columnGrants ?? {};
|
|
171
277
|
for (const grantee of [...new Set([...Object.keys(dcg), ...Object.keys(lcg)])].sort()) {
|
|
278
|
+
// Same exemption as the table-level grants — a column grant to an ungoverned role is
|
|
279
|
+
// still that role's access, and revoking it breaks the same pipeline.
|
|
280
|
+
if (!isGoverned(governed, grantee)) continue;
|
|
172
281
|
const target = grantee.toUpperCase() === 'PUBLIC' ? 'PUBLIC' : grantee;
|
|
173
282
|
const dPriv = dcg[grantee] ?? {};
|
|
174
283
|
const lPriv = lcg[grantee] ?? {};
|