@everystack/cli 0.4.105 → 0.4.107
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/CHANGELOG.md +34 -0
- package/package.json +3 -3
- package/src/cli/alter-type-dependents.ts +199 -0
- package/src/cli/apply-execute.ts +18 -0
- package/src/cli/commands/db-apply.ts +54 -7
- package/src/cli/commands/db-branch.ts +2 -0
- package/src/cli/commands/db-build.ts +2 -1
- package/src/cli/commands/db-check.ts +12 -1
- package/src/cli/commands/db-plan.ts +31 -12
- package/src/cli/commands/db-reconcile.ts +11 -2
- package/src/cli/commands/db-sync.ts +24 -8
- package/src/cli/db-build.ts +7 -4
- package/src/cli/declared-derived.ts +11 -1
- package/src/cli/derived-catchup.ts +11 -3
- package/src/cli/edge-plan.ts +34 -2
- package/src/cli/lifecycle-preflight.ts +422 -52
- package/src/cli/state-apply.ts +53 -21
|
@@ -6,6 +6,8 @@ import { compileTableContract } from './authz-compile';
|
|
|
6
6
|
import { reconcileOwner, triggerFunctionOf } from './derived-apply';
|
|
7
7
|
import { isSessionError, type SessionRunner } from './session';
|
|
8
8
|
import { parseQualified, type SourceObject } from './derived-source';
|
|
9
|
+
import { quoteQualified } from './pg-ident';
|
|
10
|
+
import { ALWAYS_GOVERNED } from './authz-reconcile';
|
|
9
11
|
import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
|
|
10
12
|
|
|
11
13
|
export const LIFECYCLE_LOG_REFUSAL = 'everystack.destroy_log is missing; run db:provision before db:apply';
|
|
@@ -32,14 +34,127 @@ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unkno
|
|
|
32
34
|
/**
|
|
33
35
|
* The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
|
|
34
36
|
* rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
|
|
37
|
+
*
|
|
38
|
+
* `planStatements`: the reviewed plan this check runs before. A finding the plan resolves is not
|
|
39
|
+
* a refusal (see planRevokedGrants). The plan's state transaction commits before the derived
|
|
40
|
+
* catch-up creates any lifecycle function, and the post-check reads the catalog again after it.
|
|
35
41
|
*/
|
|
36
|
-
export async function lifecycleApplyRefusal(
|
|
37
|
-
|
|
42
|
+
export async function lifecycleApplyRefusal(
|
|
43
|
+
runner: QueryRunner,
|
|
44
|
+
models: readonly ModelDescriptor[],
|
|
45
|
+
roles: LifecycleRoleDeclaration = {},
|
|
46
|
+
planStatements: readonly string[] = [],
|
|
47
|
+
): Promise<string | null> {
|
|
48
|
+
return (await lifecycleApplyReport(runner, models, roles, planStatements)).refusal;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** lifecycleApplyRefusal, plus the findings the plan resolves (one line, or null when none). */
|
|
52
|
+
export async function lifecycleApplyReport(
|
|
53
|
+
runner: QueryRunner,
|
|
54
|
+
models: readonly ModelDescriptor[],
|
|
55
|
+
roles: LifecycleRoleDeclaration = {},
|
|
56
|
+
planStatements: readonly string[] = [],
|
|
57
|
+
): Promise<{ refusal: string | null; resolved: string | null }> {
|
|
58
|
+
if (!models.some(model => model.lifecycle === true)) return { refusal: null, resolved: null };
|
|
38
59
|
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
60
|
+
const shape = lifecycleLogRowRefusal(log);
|
|
61
|
+
if (shape) return { refusal: shape, resolved: null };
|
|
39
62
|
// Before the plan: a modeled table's keys are the plan's to converge (the models declare each
|
|
40
63
|
// one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
|
|
41
64
|
const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
|
|
42
|
-
|
|
65
|
+
const report = await lifecycleCatalogFindings(runner, { ...catalogInputsFromModels(models, roles), modeledTables },
|
|
66
|
+
{ planRevokes: planRevokedGrants(planStatements) });
|
|
67
|
+
return { refusal: catalogRefusalText(report), resolved: resolvedLine(report.resolved) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What `db:provision` must do before a lifecycle plan can apply, as one line naming the command,
|
|
72
|
+
* or null. The same reads the apply refuses on: everystack.destroy_log missing or out of date
|
|
73
|
+
* (lifecycleLogRowRefusal), and the lifecycle role missing or holding LOGIN or BYPASSRLS (the
|
|
74
|
+
* attributes db:provision resets). `command` is the db:provision invocation for this target.
|
|
75
|
+
*/
|
|
76
|
+
export async function lifecycleProvisionFirst(
|
|
77
|
+
runner: QueryRunner,
|
|
78
|
+
models: readonly ModelDescriptor[],
|
|
79
|
+
command: string,
|
|
80
|
+
options: { lifecycleRole?: string } = {},
|
|
81
|
+
): Promise<string | null> {
|
|
82
|
+
if (!models.some(model => model.lifecycle === true)) return null;
|
|
83
|
+
const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
|
|
84
|
+
const reasons: string[] = [];
|
|
85
|
+
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
86
|
+
if (log?.present !== true) reasons.push('everystack.destroy_log is missing');
|
|
87
|
+
else if (log.current !== true) reasons.push('everystack.destroy_log is out of date');
|
|
88
|
+
const [role] = await runner(roleSql(lc));
|
|
89
|
+
if (!truthy(role?.present)) reasons.push(`role ${lc} is missing`);
|
|
90
|
+
else {
|
|
91
|
+
const wrong = (['login', 'bypassrls'] as const).filter(key => truthy(role[key])).map(key => key.toUpperCase());
|
|
92
|
+
if (wrong.length > 0) reasons.push(`role ${lc} has ${wrong.join(', ')}`);
|
|
93
|
+
}
|
|
94
|
+
if (reasons.length === 0) return null;
|
|
95
|
+
return `run db:provision first: ${reasons.join('; ')}. Run: ${command}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The grants a reviewed plan revokes, one key per (table, grantee, privilege, column) — the exact
|
|
100
|
+
* statements the reconciler emits (authz-reconcile.ts reconcileGrants, reconcileColumnGrants):
|
|
101
|
+
*
|
|
102
|
+
* REVOKE REFERENCES, TRIGGER, TRUNCATE ON public.t FROM admin;
|
|
103
|
+
* REVOKE REFERENCES (a, b) ON public.t FROM admin;
|
|
104
|
+
*
|
|
105
|
+
* Anything else (a sequence, a statement this does not recognise) resolves nothing, so an
|
|
106
|
+
* unrecognised form can only leave a finding standing, never hide one.
|
|
107
|
+
*/
|
|
108
|
+
export function planRevokedGrants(statements: readonly string[]): ReadonlySet<string> {
|
|
109
|
+
const keys = new Set<string>();
|
|
110
|
+
for (const statement of statements) {
|
|
111
|
+
const column = /^REVOKE ([A-Z]+) \(([^()]+)\) ON (.+) FROM ([^;]+);$/.exec(statement);
|
|
112
|
+
if (column) {
|
|
113
|
+
for (const name of column[2].split(', ')) keys.add(revokeKey(column[3], column[4], column[1], name));
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const table = /^REVOKE ([A-Z]+(?:, [A-Z]+)*) ON (.+) FROM ([^;]+);$/.exec(statement);
|
|
117
|
+
if (!table || table[2].startsWith('SEQUENCE ')) continue;
|
|
118
|
+
for (const privilege of table[1].split(', ')) keys.add(revokeKey(table[2], table[3], privilege));
|
|
119
|
+
}
|
|
120
|
+
return keys;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `table` as the reconciler emits it (quoteQualified of the contract's `schema.table`). */
|
|
124
|
+
const revokeKey = (table: string, grantee: string, privilege: string, column?: string): string =>
|
|
125
|
+
JSON.stringify([table, grantee, privilege, column ?? null]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What the modules declare about the roles, beside the models (DeclaredDerived). The catalog check
|
|
129
|
+
* reads it to tell a definer role the models grant to apart from a role a request can become.
|
|
130
|
+
*
|
|
131
|
+
* requestRoles `roles: { request }`, the union across modules. Undefined = not declared, and
|
|
132
|
+
* the check runs exactly as before: every grantee counts as a request role.
|
|
133
|
+
* declaredOwners every `owner` a declared derived object names.
|
|
134
|
+
*/
|
|
135
|
+
export interface LifecycleRoleDeclaration {
|
|
136
|
+
requestRoles?: string[];
|
|
137
|
+
declaredOwners?: string[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The role declaration from a loaded declared layer; empty (not declared) when there is none. */
|
|
141
|
+
export function lifecycleRolesOf(declared: { requestRoles?: string[]; declaredOwners?: string[] } | null | undefined): LifecycleRoleDeclaration {
|
|
142
|
+
return declared?.requestRoles === undefined ? {} : { requestRoles: declared.requestRoles, declaredOwners: declared.declaredOwners ?? [] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The grantees the declaration does not classify: each counts as a request role, so the lifecycle
|
|
147
|
+
* check treats it as application-side. A warning, never a refusal. Null when the request roles are
|
|
148
|
+
* not declared (nothing is classified), when no model is a lifecycle model, or when none is left.
|
|
149
|
+
*/
|
|
150
|
+
export function unclassifiedGranteesWarning(models: readonly ModelDescriptor[], roles: LifecycleRoleDeclaration): string | null {
|
|
151
|
+
if (roles.requestRoles === undefined || !models.some(model => model.lifecycle === true)) return null;
|
|
152
|
+
const classified = new Set([...roles.requestRoles, ...(roles.declaredOwners ?? [])]);
|
|
153
|
+
const unclassified = catalogInputsFromModels(models).applicationRoles.filter(role => !classified.has(role));
|
|
154
|
+
if (unclassified.length === 0) return null;
|
|
155
|
+
return `the models grant privileges to ${unclassified.join(', ')}, which ${unclassified.length === 1 ? 'is' : 'are'} neither a declared request role `
|
|
156
|
+
+ `(roles: { request }) nor a declared owner, so the lifecycle check counts ${unclassified.length === 1 ? 'it' : 'them'} as a role a request can become. `
|
|
157
|
+
+ 'Add each to roles.request, or name it as the owner of the derived objects it owns.';
|
|
43
158
|
}
|
|
44
159
|
|
|
45
160
|
const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
|
|
@@ -85,6 +200,19 @@ export interface LifecycleCatalogInputs {
|
|
|
85
200
|
* rule 6 leaves to the plan. Absent after the state apply: every key is checked.
|
|
86
201
|
*/
|
|
87
202
|
modeledTables?: readonly string[];
|
|
203
|
+
/**
|
|
204
|
+
* The declared request roles (LifecycleRoleDeclaration). Absent = not declared: the
|
|
205
|
+
* application roles are the grantees, exactly as before. Present: see applicationRoleSet.
|
|
206
|
+
*/
|
|
207
|
+
requestRoles?: readonly string[];
|
|
208
|
+
/** Every declared derived-object owner. Read only when `requestRoles` is present. */
|
|
209
|
+
declaredOwners?: readonly string[];
|
|
210
|
+
/**
|
|
211
|
+
* The roles a fresh plan governs (ALWAYS_GOVERNED and every grantee of the models): the plan
|
|
212
|
+
* revokes any REFERENCES, TRIGGER or TRUNCATE such a role holds on a lifecycle table, since a
|
|
213
|
+
* lifecycle model cannot declare one. Absent (a db:reconcile payload): every rule-3 fix is SQL.
|
|
214
|
+
*/
|
|
215
|
+
governedRoles?: readonly string[];
|
|
88
216
|
}
|
|
89
217
|
|
|
90
218
|
/** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
|
|
@@ -127,19 +255,25 @@ export function nullifyUnrecordedFromDeclared(declared: readonly Parameters<type
|
|
|
127
255
|
|
|
128
256
|
const notApplicationRole = (lc: string) => (role: string): boolean => role !== 'PUBLIC' && role !== 'public' && role !== lc;
|
|
129
257
|
|
|
130
|
-
/** The catalog check's inputs, read from the models. */
|
|
131
|
-
export function catalogInputsFromModels(
|
|
258
|
+
/** The catalog check's inputs, read from the models (and the role declaration, when there is one). */
|
|
259
|
+
export function catalogInputsFromModels(
|
|
260
|
+
models: readonly ModelDescriptor[],
|
|
261
|
+
declaration: LifecycleRoleDeclaration = {},
|
|
262
|
+
): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[]; requestRoles?: string[]; declaredOwners?: string[]; governedRoles: string[] } {
|
|
132
263
|
const roles = new Set<string>();
|
|
264
|
+
const governed = new Set<string>(ALWAYS_GOVERNED);
|
|
133
265
|
for (const model of models) {
|
|
134
266
|
const contract = compileTableContract(model);
|
|
135
|
-
for (const role of Object.keys(contract.grants)) roles.add(role);
|
|
136
|
-
for (const role of Object.keys(contract.columnGrants ?? {})) roles.add(role);
|
|
267
|
+
for (const role of Object.keys(contract.grants)) { roles.add(role); governed.add(role); }
|
|
268
|
+
for (const role of Object.keys(contract.columnGrants ?? {})) { roles.add(role); governed.add(role); }
|
|
137
269
|
for (const policy of contract.policies) for (const role of policy.roles) roles.add(role);
|
|
138
270
|
}
|
|
139
271
|
return {
|
|
272
|
+
governedRoles: [...governed].sort(),
|
|
140
273
|
lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
|
|
141
274
|
applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
|
|
142
275
|
dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
|
|
276
|
+
...(declaration.requestRoles === undefined ? {} : { requestRoles: [...declaration.requestRoles], declaredOwners: [...(declaration.declaredOwners ?? [])] }),
|
|
143
277
|
};
|
|
144
278
|
}
|
|
145
279
|
|
|
@@ -153,9 +287,16 @@ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { l
|
|
|
153
287
|
const assumes = (x: string, y: string): string => `(pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') OR CASE
|
|
154
288
|
WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
|
|
155
289
|
ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END)`;
|
|
156
|
-
/**
|
|
290
|
+
/**
|
|
291
|
+
* `x` can act as `y`, or hold ADMIN on it (an ADMIN holder can grant `y` to itself WITH SET), or
|
|
292
|
+
* reach it through a chain of grants whose options differ link to link. MEMBER counts any chain:
|
|
293
|
+
* ADMIN-only on `i`, then `i` a member of `y` (grant `i` to itself WITH SET, SET ROLE `i`, then
|
|
294
|
+
* `y`); or SET-only on `i`, then `i` an INHERIT-only member of `y` (SET ROLE `i` holds `y`'s
|
|
295
|
+
* privileges). USAGE, SET and MEMBER WITH ADMIN OPTION are each false for both. Never in `assumes`:
|
|
296
|
+
* the side set must still leave out the RDS master's ADMIN-only membership.
|
|
297
|
+
*/
|
|
157
298
|
const reaches = (x: string, y: string): string =>
|
|
158
|
-
`(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION'))`;
|
|
299
|
+
`(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION') OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER'))`;
|
|
159
300
|
|
|
160
301
|
/**
|
|
161
302
|
* The application-side roles, as a CTE `side(oid, rolname)`: non-superusers that are, or can act
|
|
@@ -166,6 +307,115 @@ const sideCte = (apps: string[]): string => `side AS (SELECT r.oid, r.rolname::t
|
|
|
166
307
|
WHERE NOT r.rolsuper AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles a
|
|
167
308
|
WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = r.oid OR ${assumes('r.oid', 'a.oid')})))`;
|
|
168
309
|
|
|
310
|
+
/** How `x` reaches `y`, for a finding: INHERIT, SET, ADMIN or a chain of grants (the first that holds; see assumes and reaches). */
|
|
311
|
+
const reachHow = (x: string, y: string): string => `CASE WHEN pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') THEN 'INHERIT'
|
|
312
|
+
WHEN (CASE WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
|
|
313
|
+
ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END) THEN 'SET'
|
|
314
|
+
WHEN pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION') THEN 'ADMIN' ELSE 'a chain of grants' END`;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The live facts the declaration rests on. Per declared request role: does it exist, is it a
|
|
318
|
+
* superuser, is it BYPASSRLS. Per declared owner that is a grantee or a request role: does it exist,
|
|
319
|
+
* can it log in, and which request roles reach it (reaches: INHERIT, SET or ADMIN, transitively).
|
|
320
|
+
*/
|
|
321
|
+
const requestRoleSql = (request: string[], owners: string[]): string => `SELECT 'request' AS kind, d.name, o.oid IS NOT NULL AS present,
|
|
322
|
+
COALESCE(o.rolsuper, false) AS super, COALESCE(o.rolbypassrls, false) AS bypassrls, COALESCE(o.rolcanlogin, false) AS login,
|
|
323
|
+
ARRAY[]::text[] AS reached_by
|
|
324
|
+
FROM pg_catalog.unnest(${textArray(request)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name
|
|
325
|
+
UNION ALL
|
|
326
|
+
SELECT 'owner', d.name, o.oid IS NOT NULL, COALESCE(o.rolsuper, false), COALESCE(o.rolbypassrls, false), COALESCE(o.rolcanlogin, false),
|
|
327
|
+
ARRAY(SELECT 'request role ' || r.rolname::text || ' can act as ' || d.name || ' (' || ${reachHow('r.oid', 'o.oid')} || ')'
|
|
328
|
+
FROM pg_catalog.pg_roles r WHERE r.rolname = ANY(${textArray(request)}) AND r.oid <> o.oid AND ${reaches('r.oid', 'o.oid')} ORDER BY 1)
|
|
329
|
+
FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name
|
|
330
|
+
ORDER BY 1, 2`;
|
|
331
|
+
|
|
332
|
+
/** The application roles every rule reads (sideCte's input), and why each counts where the declaration can say. */
|
|
333
|
+
interface ApplicationRoleSet {
|
|
334
|
+
apps: string[];
|
|
335
|
+
/** A role in `apps` → one sentence saying why it is there. Empty when nothing is declared. */
|
|
336
|
+
why: Map<string, string>;
|
|
337
|
+
/** Live findings on the declared request roles. */
|
|
338
|
+
findings: string[];
|
|
339
|
+
/** The models' grantees (applicationRoles), PUBLIC and the lifecycle role left out. */
|
|
340
|
+
grantees: ReadonlySet<string>;
|
|
341
|
+
/** The request roles are declared. */
|
|
342
|
+
declared: boolean;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* THE one place the application-role set is computed. Rules 1, 2 and 5, the pre-exposure gate, the
|
|
347
|
+
* post-check and the reconcile path all read it.
|
|
348
|
+
*
|
|
349
|
+
* Not declared (`requestRoles` absent): the grantees, exactly as before, and no query runs.
|
|
350
|
+
*
|
|
351
|
+
* Declared: the request roles and the grantees, minus each declared owner D that holds every guard,
|
|
352
|
+
* read live: D is not a request role, D is not the lifecycle role (never in the set: PUBLIC and it are
|
|
353
|
+
* filtered out), D exists and cannot log in, and no request role reaches D. A declared owner that
|
|
354
|
+
* fails a guard stays, and `why` names the guard. A grantee in neither list stays as well. Each
|
|
355
|
+
* request role must exist, and be neither a superuser nor BYPASSRLS: either is a finding.
|
|
356
|
+
*/
|
|
357
|
+
async function applicationRoleSet(runner: QueryRunner, inputs: LifecycleCatalogInputs, lc: string): Promise<ApplicationRoleSet> {
|
|
358
|
+
const grantees = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
|
|
359
|
+
if (inputs.requestRoles === undefined) return { apps: grantees, why: new Map(), findings: [], grantees: new Set(grantees), declared: false };
|
|
360
|
+
const request = new Set([...inputs.requestRoles].filter(notApplicationRole(lc)));
|
|
361
|
+
const owners = new Set(inputs.declaredOwners ?? []);
|
|
362
|
+
const set = new Set([...request, ...grantees]);
|
|
363
|
+
const candidates = [...set].filter(role => owners.has(role)).sort();
|
|
364
|
+
const findings: string[] = [];
|
|
365
|
+
const why = new Map<string, string>();
|
|
366
|
+
for (const row of await runner(requestRoleSql([...request].sort(), candidates))) {
|
|
367
|
+
const name = String(row.name);
|
|
368
|
+
if (row.kind === 'request') {
|
|
369
|
+
if (!truthy(row.present)) {
|
|
370
|
+
findings.push(`request role ${name} does not exist. Fix: create it, or remove it from roles.request.`);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (truthy(row.super)) findings.push(`request role ${name} is a superuser: a request that becomes it passes every check. Fix: ALTER ROLE ${ident(name)} NOSUPERUSER;`);
|
|
374
|
+
if (truthy(row.bypassrls)) findings.push(`request role ${name} has BYPASSRLS: a request that becomes it skips every row-level policy. Fix: ALTER ROLE ${ident(name)} NOBYPASSRLS;`);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const failed: string[] = [];
|
|
378
|
+
if (request.has(name)) failed.push(`declared owner ${name} is a request role`);
|
|
379
|
+
if (!truthy(row.present)) failed.push(`declared owner ${name} does not exist`);
|
|
380
|
+
else if (truthy(row.login)) failed.push(`declared owner ${name} can log in`);
|
|
381
|
+
failed.push(...textList(row.reached_by));
|
|
382
|
+
if (failed.length === 0) set.delete(name);
|
|
383
|
+
else why.set(name, `${name} counts as an application role: ${failed.join('; ')}.`);
|
|
384
|
+
}
|
|
385
|
+
for (const role of grantees) {
|
|
386
|
+
if (!request.has(role) && !owners.has(role)) {
|
|
387
|
+
why.set(role, `${role} counts as an application role because the models grant it privileges, and it is neither a declared request role nor a declared owner.`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { apps: [...set].sort(), why, findings, grantees: new Set(grantees), declared: true };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Why `owner`, itself an application-side role, counts: for a rule-5 finding. The declaration's
|
|
395
|
+
* reason when it has one. Undeclared, and the owner counts only because the models grant to it (it
|
|
396
|
+
* acts as no other application role, and no other application role reaches it), the finding says
|
|
397
|
+
* so and names the declaration that tells a definer role apart. Reached, the REVOKE fix stands alone.
|
|
398
|
+
*/
|
|
399
|
+
function ownerCause(set: ApplicationRoleSet, owner: string, actsAs: readonly string[], appReach: readonly string[]): string | undefined {
|
|
400
|
+
const known = set.why.get(owner);
|
|
401
|
+
if (known) return known;
|
|
402
|
+
if (set.declared) return actsAs.length > 0 ? `${owner} can act as the application role${actsAs.length === 1 ? '' : 's'} ${actsAs.join(', ')}.` : undefined;
|
|
403
|
+
const granted = set.grantees.has(owner) && actsAs.length === 0 && appReach.length === 0;
|
|
404
|
+
return granted ? grantedCause(owner) : undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const grantedCause = (owner: string): string => `${owner} counts as an application role because the models grant it privileges. `
|
|
408
|
+
+ 'If it is a definer role that no request role reaches, declare the request roles with roles: { request: [...] } in a module.';
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Two columns for the rule-5 queries: the application roles `owner` can act as (acts_as), and the
|
|
412
|
+
* other application roles that reach `owner` (app_reach). Neither counts `owner` itself.
|
|
413
|
+
*/
|
|
414
|
+
const ownerReachColumns = (apps: string[], owner: string): string => `ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
|
|
415
|
+
WHERE a.rolname = ANY(${textArray(apps)}) AND a.oid <> ${owner} AND ${assumes(owner, 'a.oid')} ORDER BY 1) AS acts_as,
|
|
416
|
+
ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
|
|
417
|
+
WHERE a.rolname = ANY(${textArray(apps)}) AND a.oid <> ${owner} AND ${reaches('a.oid', owner)} ORDER BY 1) AS app_reach`;
|
|
418
|
+
|
|
169
419
|
/** Rule 1, attributes. */
|
|
170
420
|
const roleSql = (lc: string): string => `SELECT lc.oid IS NOT NULL AS present,
|
|
171
421
|
COALESCE(lc.rolcanlogin, false) AS login, COALESCE(lc.rolsuper, false) AS super,
|
|
@@ -184,11 +434,17 @@ const memberSql = (apps: string[], lc: string): string => `WITH ${sideCte(apps)}
|
|
|
184
434
|
WHERE s.oid <> lc.oid AND ${reaches('s.oid', 'lc.oid')}
|
|
185
435
|
ORDER BY 1`;
|
|
186
436
|
|
|
437
|
+
/** The deploy history: who owns one can rewrite what was applied, run and reconciled. */
|
|
438
|
+
const HISTORY_TABLES = ['everystack.schema_log', 'everystack.exec_log', 'everystack.derived_provenance'];
|
|
439
|
+
|
|
187
440
|
/**
|
|
188
441
|
* Rule 2. The owner of a lifecycle table can disable its triggers; the owner of destroy_log can
|
|
189
|
-
* write the marker; the owner of a marker function can replace it
|
|
190
|
-
*
|
|
191
|
-
*
|
|
442
|
+
* write the marker; the owner of a marker function can replace it; the owner of a schema holding
|
|
443
|
+
* any of them can drop what is in it (DROP ... CASCADE takes the delete-path trigger along); the
|
|
444
|
+
* owner of schema_log, exec_log or derived_provenance can forge the deploy history. Each such
|
|
445
|
+
* object is a finding when its owner is the lifecycle role, or when an application-side role is
|
|
446
|
+
* that owner, can act as it, or holds ADMIN on it. The operator owning all of them is the
|
|
447
|
+
* documented setup and passes. A table that does not exist is not judged.
|
|
192
448
|
*/
|
|
193
449
|
const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH ${sideCte(apps)},
|
|
194
450
|
owned AS (
|
|
@@ -200,8 +456,19 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
|
|
|
200
456
|
WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log')
|
|
201
457
|
UNION ALL
|
|
202
458
|
SELECT 'function', m.sig, p.proowner FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
|
|
203
|
-
JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)
|
|
459
|
+
JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)
|
|
460
|
+
UNION ALL
|
|
461
|
+
SELECT 'table', n.nspname::text || '.' || c.relname::text, c.relowner
|
|
462
|
+
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
463
|
+
WHERE c.oid IN (SELECT pg_catalog.to_regclass(t) FROM pg_catalog.unnest(${textArray(HISTORY_TABLES)}) AS t)
|
|
464
|
+
UNION ALL
|
|
465
|
+
SELECT 'schema', n.nspname::text, n.nspowner FROM pg_catalog.pg_namespace n
|
|
466
|
+
WHERE n.nspname = ANY(${textArray([...new Set(tables.map(table => table.slice(0, table.indexOf('.'))))])})
|
|
467
|
+
OR n.oid IN (SELECT c.relnamespace FROM pg_catalog.pg_class c WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log'))
|
|
468
|
+
OR n.oid IN (SELECT p.pronamespace FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
|
|
469
|
+
JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)))
|
|
204
470
|
SELECT o.kind, o.name, pg_catalog.pg_get_userbyid(o.owner)::text AS owner, o.owner = lc.oid AS lifecycle_owned,
|
|
471
|
+
pg_catalog.current_database()::text AS database,
|
|
205
472
|
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')} ORDER BY 1) AS via
|
|
206
473
|
FROM owned o LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
|
|
207
474
|
WHERE o.owner = lc.oid OR EXISTS (SELECT 1 FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')})
|
|
@@ -211,23 +478,27 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
|
|
|
211
478
|
* Rule 3. TRIGGER, REFERENCES or TRUNCATE on a lifecycle table, or REFERENCES on one of its
|
|
212
479
|
* columns, held by anyone but its owner. On everystack.destroy_log, any privilege but SELECT
|
|
213
480
|
* (table or column) held by anyone but its owner: a writer could forge or remove a marker.
|
|
481
|
+
* One row per grantor: PostgreSQL revokes only the grants made by the role REVOKE runs as, and
|
|
482
|
+
* runs a member's (or a superuser's) REVOKE as the owner, so who granted it decides what removes it.
|
|
214
483
|
*/
|
|
215
484
|
const aclSql = (tables: string[]): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", ${granteeName('a.grantee')} AS grantee,
|
|
216
|
-
a.privilege_type AS privilege, NULL::text AS "column"
|
|
485
|
+
a.privilege_type AS privilege, NULL::text AS "column", a.grantor = c.relowner AS owner_granted,
|
|
486
|
+
pg_catalog.pg_get_userbyid(a.grantor)::text AS grantor
|
|
217
487
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
218
488
|
CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) a
|
|
219
489
|
WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
|
|
220
490
|
((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type IN ('TRIGGER', 'REFERENCES', 'TRUNCATE'))
|
|
221
491
|
OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
|
|
222
492
|
UNION ALL
|
|
223
|
-
SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text
|
|
493
|
+
SELECT n.nspname::text || '.' || c.relname::text, ${granteeName('a.grantee')}, a.privilege_type, t.attname::text,
|
|
494
|
+
a.grantor = c.relowner, pg_catalog.pg_get_userbyid(a.grantor)::text
|
|
224
495
|
FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
225
496
|
JOIN pg_catalog.pg_attribute t ON t.attrelid = c.oid AND t.attnum > 0 AND NOT t.attisdropped
|
|
226
497
|
CROSS JOIN LATERAL pg_catalog.aclexplode(t.attacl) a
|
|
227
498
|
WHERE c.relkind IN ('r', 'p') AND a.grantee <> c.relowner AND (
|
|
228
499
|
((n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)}) AND a.privilege_type = 'REFERENCES')
|
|
229
500
|
OR (c.oid = pg_catalog.to_regclass('everystack.destroy_log') AND a.privilege_type <> 'SELECT'))
|
|
230
|
-
ORDER BY 1, 4 NULLS FIRST, 3, 2`;
|
|
501
|
+
ORDER BY 1, 4 NULLS FIRST, 3, 2, 6`;
|
|
231
502
|
|
|
232
503
|
/**
|
|
233
504
|
* Rule 4. Each marker function: SECURITY DEFINER, owned by destroy_log's owner, EXECUTE for the
|
|
@@ -260,7 +531,8 @@ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${si
|
|
|
260
531
|
SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
|
|
261
532
|
pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
|
|
262
533
|
pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
|
|
263
|
-
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via
|
|
534
|
+
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via,
|
|
535
|
+
${ownerReachColumns(apps, 'p.proowner')}
|
|
264
536
|
FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
|
|
265
537
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
|
|
266
538
|
JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
|
|
@@ -272,8 +544,12 @@ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${si
|
|
|
272
544
|
* One rule-5 finding. `via` holds the application-side roles that are, or reach, `owner`. When the
|
|
273
545
|
* owner is one of them the fix is a new owner; otherwise it is to revoke the path to the owner.
|
|
274
546
|
* `would`: the pre-exposure gate, which judges a declared trigger before the reconcile creates it.
|
|
547
|
+
* `cause`: why the owner counts as an application role (ownerCause), when there is more to say.
|
|
275
548
|
*/
|
|
276
|
-
export function triggerOwnerFinding(
|
|
549
|
+
export function triggerOwnerFinding(
|
|
550
|
+
row: { trigger: string; table: string; function: string; owner: string; via: readonly string[]; cause?: string },
|
|
551
|
+
would = false,
|
|
552
|
+
): string {
|
|
277
553
|
const { owner, via } = row;
|
|
278
554
|
const sideOwned = via.includes(owner);
|
|
279
555
|
const who = sideOwned ? `${owner}, an application-side role`
|
|
@@ -284,7 +560,8 @@ export function triggerOwnerFinding(row: { trigger: string; table: string; funct
|
|
|
284
560
|
+ `for a function not declared in db/models: ALTER FUNCTION ${row.function} OWNER TO <that role>;`
|
|
285
561
|
: `${via.map(r => `REVOKE ${ident(owner)} FROM ${ident(r)};`).join(' ')} (or from the role each reaches ${owner} through)`;
|
|
286
562
|
return `trigger ${row.trigger} on ${row.table} ${would ? 'would run' : 'runs'} ${row.function}, owned by ${who}; `
|
|
287
|
-
+ `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}
|
|
563
|
+
+ `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`
|
|
564
|
+
+ (sideOwned && row.cause ? ` ${row.cause}` : '');
|
|
288
565
|
}
|
|
289
566
|
|
|
290
567
|
/**
|
|
@@ -307,6 +584,8 @@ const OWNER_HAZARD = {
|
|
|
307
584
|
table: 'an owner can disable its triggers',
|
|
308
585
|
log: 'an owner can write the marker',
|
|
309
586
|
function: 'an owner can replace the function',
|
|
587
|
+
schema: 'an owner can DROP ... CASCADE the delete-path trigger',
|
|
588
|
+
history: 'an owner can forge the deploy history',
|
|
310
589
|
};
|
|
311
590
|
|
|
312
591
|
/**
|
|
@@ -331,26 +610,55 @@ export async function lifecycleCatalogRefusal(
|
|
|
331
610
|
inputs: LifecycleCatalogInputs,
|
|
332
611
|
options: { lifecycleRole?: string } = {},
|
|
333
612
|
): Promise<string | null> {
|
|
334
|
-
|
|
613
|
+
return catalogRefusalText(await lifecycleCatalogFindings(runner, inputs, options));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const catalogRefusalText = ({ findings, resolved }: CatalogFindings): string | null => {
|
|
335
617
|
if (findings.length === 0) return null;
|
|
336
|
-
|
|
618
|
+
const also = resolved.length === 0 ? '' : ` (the plan resolves ${resolved.length} more)`;
|
|
619
|
+
return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'})${also}; `
|
|
337
620
|
+ `fix each, then re-run:\n${findingLines(findings)}`;
|
|
338
|
-
}
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* The resolved rule-3 findings as one line: per grantee and privilege, the tables the plan
|
|
625
|
+
* revokes it on. Null when the plan resolves none.
|
|
626
|
+
*/
|
|
627
|
+
const resolvedLine = (resolved: readonly ResolvedGrant[]): string | null => {
|
|
628
|
+
if (resolved.length === 0) return null;
|
|
629
|
+
const byGrant = new Map<string, string[]>();
|
|
630
|
+
for (const r of resolved) {
|
|
631
|
+
const grant = `${r.privilege}${r.column ? ` (${r.column})` : ''} from ${r.grantee}`;
|
|
632
|
+
byGrant.set(grant, [...(byGrant.get(grant) ?? []), r.table]);
|
|
633
|
+
}
|
|
634
|
+
const parts = [...byGrant].map(([grant, tables]) => `${grant} on ${[...new Set(tables)].sort().join(', ')}`);
|
|
635
|
+
return `the plan resolves ${countFindings(resolved.length)}: it revokes ${parts.join('; ')}, before any lifecycle function is created.`;
|
|
636
|
+
};
|
|
339
637
|
|
|
340
638
|
const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
|
|
341
639
|
const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
|
|
342
640
|
|
|
343
|
-
/**
|
|
641
|
+
/** A rule-3 finding the reviewed plan's own REVOKE removes. */
|
|
642
|
+
interface ResolvedGrant { table: string; grantee: string; privilege: string; column: string | null }
|
|
643
|
+
interface CatalogFindings { findings: string[]; resolved: ResolvedGrant[] }
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* lifecycleCatalogRefusal's findings, one string each, without the heading. `planRevokes`
|
|
647
|
+
* (planRevokedGrants): a rule-3 grant on a lifecycle table that the plan revokes, exactly that
|
|
648
|
+
* privilege from that grantee on that table (and column), moves to `resolved`. Nothing else does.
|
|
649
|
+
*/
|
|
344
650
|
async function lifecycleCatalogFindings(
|
|
345
651
|
runner: QueryRunner,
|
|
346
652
|
inputs: LifecycleCatalogInputs,
|
|
347
|
-
options: { lifecycleRole?: string } = {},
|
|
348
|
-
): Promise<
|
|
653
|
+
options: { lifecycleRole?: string; planRevokes?: ReadonlySet<string> } = {},
|
|
654
|
+
): Promise<CatalogFindings> {
|
|
349
655
|
const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
|
|
350
656
|
const tables = [...new Set(inputs.lifecycleTables)].sort();
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
657
|
+
const resolved: ResolvedGrant[] = [];
|
|
658
|
+
if (tables.length === 0) return { findings: [], resolved };
|
|
659
|
+
const set = await applicationRoleSet(runner, inputs, lc);
|
|
660
|
+
const apps = set.apps;
|
|
661
|
+
const findings: string[] = [...set.findings];
|
|
354
662
|
|
|
355
663
|
const [role] = await runner(roleSql(lc));
|
|
356
664
|
if (!truthy(role?.present)) {
|
|
@@ -369,24 +677,55 @@ async function lifecycleCatalogFindings(
|
|
|
369
677
|
}
|
|
370
678
|
|
|
371
679
|
for (const row of await runner(ownerSql(tables, apps, lc))) {
|
|
372
|
-
const kind = row.kind === 'function'
|
|
373
|
-
const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log
|
|
680
|
+
const kind: 'function' | 'schema' | 'table' = row.kind === 'function' || row.kind === 'schema' ? row.kind : 'table';
|
|
681
|
+
const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log
|
|
682
|
+
: HISTORY_TABLES.includes(String(row.name)) ? OWNER_HAZARD.history : OWNER_HAZARD[kind];
|
|
374
683
|
const via = textList(row.via);
|
|
375
684
|
const who = truthy(row.lifecycle_owned) ? `${row.owner}, the lifecycle role`
|
|
376
685
|
: via.includes(String(row.owner)) ? `${row.owner}, an application-side role`
|
|
377
686
|
: `${row.owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
|
|
378
|
-
const name = kind === 'function' ? String(row.name) : quoteLabel(String(row.name));
|
|
687
|
+
const name = kind === 'function' ? String(row.name) : kind === 'schema' ? ident(String(row.name)) : quoteLabel(String(row.name));
|
|
688
|
+
// pg_database_owner (public's owner on PostgreSQL 15 and later) has one implicit member, the owner
|
|
689
|
+
// of the database, and REVOKE cannot remove it: the database itself has to move.
|
|
379
690
|
const fix = truthy(row.lifecycle_owned) || via.includes(String(row.owner))
|
|
380
691
|
? `ALTER ${kind.toUpperCase()} ${name} OWNER TO <your operator role>;`
|
|
381
|
-
:
|
|
382
|
-
|
|
692
|
+
: row.owner === 'pg_database_owner'
|
|
693
|
+
? `ALTER DATABASE ${ident(String(row.database))} OWNER TO <your operator role>; (the owner of the database is pg_database_owner's only member)`
|
|
694
|
+
: `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
|
|
695
|
+
const cause = via.includes(String(row.owner)) ? set.why.get(String(row.owner)) : undefined;
|
|
696
|
+
findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}${cause ? ` ${cause}` : ''}`);
|
|
383
697
|
}
|
|
384
698
|
|
|
699
|
+
const governed = new Set(inputs.governedRoles ?? []);
|
|
385
700
|
for (const row of await runner(aclSql(tables))) {
|
|
701
|
+
const column = row.column == null ? null : String(row.column);
|
|
386
702
|
const on = row.column == null ? '' : ` (${ident(String(row.column))})`;
|
|
703
|
+
// Granted by another role WITH GRANT OPTION: the plan's REVOKE (run as the owner) and a fresh
|
|
704
|
+
// plan both leave it, so it is never resolved. No verb removes it today (item U6).
|
|
705
|
+
if (!truthy(row.owner_granted)) {
|
|
706
|
+
const table = quoteLabel(String(row.table));
|
|
707
|
+
const grantor = ident(String(row.grantor));
|
|
708
|
+
const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
|
|
709
|
+
// On destroy_log the revoke comes from db:provision, not from a plan.
|
|
710
|
+
const revoker = row.table === 'everystack.destroy_log' ? "db:provision's REVOKE" : "the plan's REVOKE";
|
|
711
|
+
findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}, granted by ${row.grantor} with grant option; `
|
|
712
|
+
+ `${revoker} runs as the owner and cannot remove it. Fix: REVOKE ${row.privilege}${on} ON TABLE ${table} FROM ${who} GRANTED BY ${grantor}; `
|
|
713
|
+
+ `(as ${row.grantor}, a member of it, or a superuser), or REVOKE GRANT OPTION FOR ${row.privilege}${on} ON TABLE ${table} FROM ${grantor} CASCADE;`);
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
if (options.planRevokes && tables.includes(String(row.table))
|
|
717
|
+
&& options.planRevokes.has(revokeKey(quoteQualified(String(row.table)), String(row.grantee), String(row.privilege), column ?? undefined))) {
|
|
718
|
+
resolved.push({ table: String(row.table), grantee: String(row.grantee), privilege: String(row.privilege), column });
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
387
721
|
const who = row.grantee === 'PUBLIC' ? 'PUBLIC' : ident(String(row.grantee));
|
|
388
|
-
|
|
389
|
-
|
|
722
|
+
// A verb where one exists: db:provision revokes every grant on destroy_log but its owner's
|
|
723
|
+
// (ENSURE_DESTROY_LOG_SQL); a fresh plan revokes a governed role's grant on a lifecycle table.
|
|
724
|
+
// SQL only for a role no plan governs, which the plan leaves alone by design.
|
|
725
|
+
const fix = row.table === 'everystack.destroy_log' ? 'run db:provision.'
|
|
726
|
+
: governed.has(String(row.grantee)) ? 'mint a plan (db:plan) and db:apply it; the plan revokes this grant.'
|
|
727
|
+
: `REVOKE ${row.privilege}${on} ON TABLE ${quoteLabel(String(row.table))} FROM ${who};`;
|
|
728
|
+
findings.push(`${row.grantee} holds ${row.privilege}${on} on ${row.table}. Fix: ${fix}`);
|
|
390
729
|
}
|
|
391
730
|
|
|
392
731
|
for (const row of await runner(markerSql(lc))) {
|
|
@@ -413,8 +752,10 @@ async function lifecycleCatalogFindings(
|
|
|
413
752
|
}
|
|
414
753
|
|
|
415
754
|
for (const row of await runner(triggerOwnerSql(tables, apps))) {
|
|
755
|
+
const owner = String(row.owner);
|
|
756
|
+
const via = textList(row.via);
|
|
416
757
|
findings.push(triggerOwnerFinding({ trigger: String(row.trigger), table: String(row.table), function: String(row.function),
|
|
417
|
-
owner:
|
|
758
|
+
owner, via, cause: ownerCause(set, owner, textList(row.acts_as), textList(row.app_reach)) }));
|
|
418
759
|
}
|
|
419
760
|
|
|
420
761
|
const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
|
|
@@ -443,7 +784,7 @@ async function lifecycleCatalogFindings(
|
|
|
443
784
|
+ 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
|
|
444
785
|
}
|
|
445
786
|
|
|
446
|
-
return findings;
|
|
787
|
+
return { findings, resolved };
|
|
447
788
|
}
|
|
448
789
|
|
|
449
790
|
/**
|
|
@@ -472,10 +813,10 @@ export async function lifecycleDeclaredOwnerRefusal(
|
|
|
472
813
|
session: SessionRunner,
|
|
473
814
|
declared: readonly SourceObject[],
|
|
474
815
|
models: readonly ModelDescriptor[],
|
|
475
|
-
options: { stateApplied: boolean },
|
|
816
|
+
options: { stateApplied: boolean; roles?: LifecycleRoleDeclaration },
|
|
476
817
|
): Promise<string | null> {
|
|
477
|
-
const
|
|
478
|
-
const tables = new Set(lifecycleTables);
|
|
818
|
+
const inputs = catalogInputsFromModels(models, options.roles);
|
|
819
|
+
const tables = new Set(inputs.lifecycleTables);
|
|
479
820
|
const triggers = declared.flatMap((object) => {
|
|
480
821
|
if (object.kind !== 'trigger' || !object.table) return [];
|
|
481
822
|
const { schema, name } = parseQualified(object.table);
|
|
@@ -488,20 +829,24 @@ export async function lifecycleDeclaredOwnerRefusal(
|
|
|
488
829
|
const builder = String(slot[0].builder);
|
|
489
830
|
const judged = triggers.map(t => ({ ...t, owner: reconcileOwner(t.fn, builder) }));
|
|
490
831
|
const owners = [...new Set(judged.map(t => t.owner))].sort();
|
|
491
|
-
const
|
|
832
|
+
const set = await applicationRoleSet(runner, inputs, LIFECYCLE_OWNER_ROLE);
|
|
833
|
+
const rows = await runner(`WITH ${sideCte(set.apps)}
|
|
492
834
|
SELECT d.name AS owner, o.oid IS NOT NULL AS present,
|
|
493
|
-
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via
|
|
835
|
+
ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via,
|
|
836
|
+
${ownerReachColumns(set.apps, 'o.oid')}
|
|
494
837
|
FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name`);
|
|
495
|
-
const byOwner = new Map(rows.map(row => [String(row.owner),
|
|
496
|
-
|
|
838
|
+
const byOwner = new Map(rows.map(row => [String(row.owner),
|
|
839
|
+
{ present: truthy(row.present), via: textList(row.via), actsAs: textList(row.acts_as), appReach: textList(row.app_reach) }]));
|
|
840
|
+
const findings = [...set.findings, ...judged.flatMap((t) => {
|
|
497
841
|
const owner = byOwner.get(t.owner);
|
|
498
842
|
if (!owner?.present) {
|
|
499
843
|
return [`trigger ${t.trigger} on ${t.table} would run ${t.fn.identity}, owned by ${t.owner}, which does not exist. `
|
|
500
844
|
+ `Fix: create ${t.owner} as a role no application role can act as, or declare an existing such role as ${t.fn.identity}'s owner in db/models.`];
|
|
501
845
|
}
|
|
502
846
|
if (owner.via.length === 0) return [];
|
|
503
|
-
return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via
|
|
504
|
-
|
|
847
|
+
return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via,
|
|
848
|
+
cause: ownerCause(set, t.owner, owner.actsAs, owner.appReach) }, true)];
|
|
849
|
+
})];
|
|
505
850
|
if (findings.length === 0) return null;
|
|
506
851
|
return `${options.stateApplied ? 'tables and columns applied; ' : ''}derived layer NOT applied: ${countFindings(findings.length)}; `
|
|
507
852
|
+ `fix each, then re-run:\n${findingLines(findings)}`;
|
|
@@ -515,14 +860,18 @@ export async function lifecycleDeclaredOwnerRefusal(
|
|
|
515
860
|
* It reads on `session`, the reconcile's own connection: on a stage, db:session (the operator
|
|
516
861
|
* connection). db:query may read a replica the reconcile's commit has not reached yet, and pass.
|
|
517
862
|
*/
|
|
518
|
-
export async function lifecyclePostApplyRefusal(
|
|
863
|
+
export async function lifecyclePostApplyRefusal(
|
|
864
|
+
session: SessionRunner,
|
|
865
|
+
models: readonly ModelDescriptor[],
|
|
866
|
+
roles: LifecycleRoleDeclaration = {},
|
|
867
|
+
): Promise<string | null> {
|
|
519
868
|
if (!models.some(model => model.lifecycle === true)) return null;
|
|
520
869
|
const read: QueryRunner = async (q) => {
|
|
521
870
|
const [slot] = await session([q]);
|
|
522
871
|
if (isSessionError(slot)) throw new Error(slot.message);
|
|
523
872
|
return slot ?? [];
|
|
524
873
|
};
|
|
525
|
-
const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models));
|
|
874
|
+
const { findings } = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
|
|
526
875
|
if (findings.length === 0) return null;
|
|
527
876
|
return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
|
|
528
877
|
}
|
|
@@ -536,6 +885,20 @@ export const LIFECYCLE_MANIFEST_ABSENT_REFUSAL = (functions: readonly string[]):
|
|
|
536
885
|
+ `function${functions.length === 1 ? '' : 's'} ${functions.join(', ')} carr${functions.length === 1 ? 'ies' : 'y'} no manifest, so the declared dependents `
|
|
537
886
|
+ 'cannot be read and the lifecycle catalog check cannot run. Compile the declared layer from the models with this CLI and re-run.';
|
|
538
887
|
|
|
888
|
+
/**
|
|
889
|
+
* The role declaration is optional on the wire: absent, the check runs as before (every grantee
|
|
890
|
+
* counts). Present but not a list of role names, the check cannot tell what was declared: fail closed.
|
|
891
|
+
*/
|
|
892
|
+
export const LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL = (field: 'request roles' | 'declared owners'): string => `this db:reconcile payload `
|
|
893
|
+
+ `carries ${field} that are not a list of role names, so the lifecycle catalog check cannot run. `
|
|
894
|
+
+ 'Compile the declared layer from the models with this CLI and re-run.';
|
|
895
|
+
|
|
896
|
+
/** An empty list treats no role as a request role, so no request role could reach a declared owner. */
|
|
897
|
+
export const LIFECYCLE_REQUEST_ROLES_EMPTY_REFUSAL = 'this db:reconcile payload declares an empty list of request roles, so every declared owner '
|
|
898
|
+
+ 'would pass the request-role guard. defineModule refuses that list; compile the declared layer from the models with this CLI and re-run.';
|
|
899
|
+
|
|
900
|
+
const isRoleList = (value: unknown): value is string[] => Array.isArray(value) && value.every(role => typeof role === 'string');
|
|
901
|
+
|
|
539
902
|
/** Generated by an older CLI (0.4.101's per-row restrict pair); a payload may still carry one it is replacing. */
|
|
540
903
|
const RETIRED_LIFECYCLE_ROLES = ['restrict'] as const;
|
|
541
904
|
|
|
@@ -570,13 +933,15 @@ export function lifecycleTablesFromDeclared(declared: readonly SourceObject[]):
|
|
|
570
933
|
/**
|
|
571
934
|
* db:reconcile's lifecycle gate, local and in the ops Lambda alike: the destroy_log shape, the
|
|
572
935
|
* declared layer's own consistency, the application roles' presence (fail closed), then the
|
|
573
|
-
* catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles
|
|
574
|
-
* because the ops Lambda reads
|
|
936
|
+
* catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles`, `requestRoles`
|
|
937
|
+
* and `declaredOwners` are `unknown` because the ops Lambda reads them off the wire. The last two are
|
|
938
|
+
* optional: an older CLI sends neither, and the check runs as before. An older server ignores them:
|
|
939
|
+
* it refuses more, and it skips the live checks on the request roles (see ReconcileWirePayload).
|
|
575
940
|
*/
|
|
576
941
|
export async function lifecycleReconcileRefusal(
|
|
577
942
|
runner: QueryRunner,
|
|
578
943
|
declared: readonly SourceObject[],
|
|
579
|
-
options: { applicationRoles?: unknown; lifecycleRole?: string } = {},
|
|
944
|
+
options: { applicationRoles?: unknown; requestRoles?: unknown; declaredOwners?: unknown; lifecycleRole?: string } = {},
|
|
580
945
|
): Promise<string | null> {
|
|
581
946
|
if (!declared.some(object => object.owner === LIFECYCLE_OWNER_ROLE)) return null;
|
|
582
947
|
const [log] = await runner(LIFECYCLE_LOG_SQL);
|
|
@@ -586,11 +951,16 @@ export async function lifecycleReconcileRefusal(
|
|
|
586
951
|
if ('refusal' in derived) return derived.refusal;
|
|
587
952
|
const roles = options.applicationRoles;
|
|
588
953
|
if (!Array.isArray(roles) || roles.some(role => typeof role !== 'string')) return LIFECYCLE_ROLES_ABSENT_REFUSAL;
|
|
954
|
+
const { requestRoles, declaredOwners } = options;
|
|
955
|
+
if (requestRoles != null && !isRoleList(requestRoles)) return LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL('request roles');
|
|
956
|
+
if (declaredOwners != null && !isRoleList(declaredOwners)) return LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL('declared owners');
|
|
957
|
+
if (Array.isArray(requestRoles) && requestRoles.length === 0) return LIFECYCLE_REQUEST_ROLES_EMPTY_REFUSAL;
|
|
589
958
|
const unreadable = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE && object.name.endsWith('_lifecycle_transition')
|
|
590
959
|
&& !lifecycleManifestOf(object)).map(object => object.identity).sort();
|
|
591
960
|
if (unreadable.length > 0) return LIFECYCLE_MANIFEST_ABSENT_REFUSAL(unreadable);
|
|
592
961
|
return lifecycleCatalogRefusal(runner, { lifecycleTables: derived.tables, applicationRoles: roles, dependentKeys: dependentKeysFromDeclared(declared),
|
|
593
|
-
nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared)
|
|
962
|
+
nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared),
|
|
963
|
+
...(requestRoles == null ? {} : { requestRoles, declaredOwners: isRoleList(declaredOwners) ? declaredOwners : [] }) },
|
|
594
964
|
{ lifecycleRole: options.lifecycleRole });
|
|
595
965
|
}
|
|
596
966
|
|