@everystack/server 0.4.29 → 0.4.31
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 +35 -0
- package/package.json +3 -3
- package/src/db.ts +43 -2
- package/src/model-wire.ts +94 -0
- package/src/owner-adoption.ts +75 -4
- package/src/owner-enactment.ts +355 -20
- package/src/plugin.ts +280 -60
- package/src/provision.ts +265 -16
- package/src/role-chain.ts +114 -0
- package/src/sql-lex.ts +217 -0
package/src/owner-enactment.ts
CHANGED
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
* memberships, no grants, and no review.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
import { generateReaderGrantSQL } from './role-chain';
|
|
32
|
+
|
|
31
33
|
/** PostgreSQL 16 — the first version with `GRANT ... WITH INHERIT FALSE, SET TRUE` and role `SET` membership. */
|
|
32
34
|
export const MIN_SERVER_VERSION_NUM = 160000;
|
|
33
35
|
|
|
@@ -88,8 +90,34 @@ export interface GranterCapabilityRow {
|
|
|
88
90
|
canGrantCreate: boolean;
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/** Which connection runs a unit's statements. */
|
|
94
|
+
export type EnactmentConnection = 'operator' | 'master';
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* ONE independently-failable piece of the plan: everything one SUBJECT needs on one connection.
|
|
98
|
+
*
|
|
99
|
+
* The subject is a declared owner (ENACT) or one of the reader's grant kinds — never the whole run.
|
|
100
|
+
* That grain is the point: the subjects are independent of each other and every statement is
|
|
101
|
+
* idempotent, so a failure on one must never take the others down. Applying the run as one batch
|
|
102
|
+
* did exactly that — a single unroutable owner rolled back a co-batched, perfectly grantable owner,
|
|
103
|
+
* which then never converged on any later re-run because the same batch failed the same way.
|
|
104
|
+
*/
|
|
105
|
+
export interface EnactmentUnit {
|
|
106
|
+
/** What this converges, named the way a refusal names it: `owner 'report_owner' in schema 'api'`. */
|
|
107
|
+
subject: string;
|
|
108
|
+
connection: EnactmentConnection;
|
|
109
|
+
/** Applied together (ENACT: one transaction). Never interleaved with another subject's. */
|
|
110
|
+
statements: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
/** The routed enactment: statements the operator runs, statements the master must run, and refusals. */
|
|
92
114
|
export interface EnactmentPlan {
|
|
115
|
+
/**
|
|
116
|
+
* The independently-failable units, in order. This is the plan the applier runs; `asOperator` and
|
|
117
|
+
* `asMaster` are the flattened view of the same statements, kept for reporting and for callers
|
|
118
|
+
* that only need "what will run where".
|
|
119
|
+
*/
|
|
120
|
+
units: EnactmentUnit[];
|
|
93
121
|
/** Runnable on the operator's own connection. */
|
|
94
122
|
asOperator: string[];
|
|
95
123
|
/** Must run on the master connection (the operator lacks the grant right). */
|
|
@@ -98,6 +126,48 @@ export interface EnactmentPlan {
|
|
|
98
126
|
refusals: string[];
|
|
99
127
|
}
|
|
100
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Accumulate units and keep the flattened per-connection views in step. A subject that needs
|
|
131
|
+
* statements on BOTH connections gets one unit per connection — they cannot share a transaction.
|
|
132
|
+
*/
|
|
133
|
+
interface PlanBuilder {
|
|
134
|
+
plan: EnactmentPlan;
|
|
135
|
+
byKey: Map<string, EnactmentUnit>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function newPlan(): PlanBuilder {
|
|
139
|
+
return { plan: { units: [], asOperator: [], asMaster: [], refusals: [] }, byKey: new Map() };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function addUnit(b: PlanBuilder, subject: string, connection: EnactmentConnection, statements: string[]): void {
|
|
143
|
+
const key = `${subject} ${connection}`;
|
|
144
|
+
let unit = b.byKey.get(key);
|
|
145
|
+
if (!unit) {
|
|
146
|
+
unit = { subject, connection, statements: [] };
|
|
147
|
+
b.byKey.set(key, unit);
|
|
148
|
+
b.plan.units.push(unit);
|
|
149
|
+
}
|
|
150
|
+
unit.statements.push(...statements);
|
|
151
|
+
(connection === 'operator' ? b.plan.asOperator : b.plan.asMaster).push(...statements);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The refusal a unit that FAILED on the server becomes.
|
|
156
|
+
*
|
|
157
|
+
* Every other refusal here is pre-flight — the probe said no connection can do it, so nothing ran.
|
|
158
|
+
* This one is the residue: a capability probe can be honest and the statement still fail (a
|
|
159
|
+
* membership cycle the server rejects, a concurrent REVOKE between probe and apply). It is reported
|
|
160
|
+
* against the ONE subject it belongs to, carries the same runnable fix SQL as its pre-flight
|
|
161
|
+
* siblings, and says plainly that nothing else in the run was rolled back with it.
|
|
162
|
+
*/
|
|
163
|
+
export function unitRefusal(unit: EnactmentUnit, message: string): string {
|
|
164
|
+
const fix = unit.statements.map((s) => `${s};`).join(' ');
|
|
165
|
+
return (
|
|
166
|
+
`cannot converge ${unit.subject}: ${String(message).trim()} — refused alone; ` +
|
|
167
|
+
`every other subject in this run stands. Run as a role that can: ${fix}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
101
171
|
const IDENT = /^[a-z_][a-z0-9_]*$/;
|
|
102
172
|
function assertIdent(name: string, what: string): void {
|
|
103
173
|
if (!IDENT.test(name)) throw new Error(`Invalid ${what}: ${JSON.stringify(name)} — must match ${IDENT}`);
|
|
@@ -135,10 +205,21 @@ ORDER BY d.owner_name, d.schema_name
|
|
|
135
205
|
|
|
136
206
|
/**
|
|
137
207
|
* The connection-DEPENDENT probe: run on a connection to learn what CURRENT_USER can grant.
|
|
138
|
-
* Can it `GRANT <owner> TO <operator>` — it holds ADMIN OPTION on the owner
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
208
|
+
* Can it `GRANT <owner> TO <operator>` — it holds ADMIN OPTION on the owner, directly or through a
|
|
209
|
+
* group. Can it `GRANT CREATE ON SCHEMA <schema>` — it owns the schema or holds CREATE WITH GRANT
|
|
210
|
+
* OPTION. A REAL superuser can do both unconditionally. Run on the operator's connection and, when
|
|
211
|
+
* present, on the master's; the router merges the two.
|
|
212
|
+
*
|
|
213
|
+
* MEMBERSHIP IN `rds_superuser` IS NOT SUPERUSER, and this probe no longer pretends it is. RDS's
|
|
214
|
+
* master is `NOSUPERUSER CREATEROLE` and a member of the `rds_superuser` group; treating that
|
|
215
|
+
* membership as `rolsuper` reported `can_grant_membership: true` for a role the master demonstrably
|
|
216
|
+
* cannot grant — a DBA-created role it holds no ADMIN OPTION on — so `planOwnerEnactment` routed the
|
|
217
|
+
* GRANT to the master instead of emitting its precise refusal, and the statement failed mid-batch
|
|
218
|
+
* (live-confirmed on PostgreSQL 16.13). Whatever `rds_superuser` membership REALLY confers is
|
|
219
|
+
* already visible to the honest reads below: `pg_has_role(..., 'MEMBER WITH ADMIN OPTION')` follows
|
|
220
|
+
* indirect admin through a group, and `has_schema_privilege` follows inherited ACLs. The shortcut
|
|
221
|
+
* added nothing but false positives. The module's contract — the master's capability is PROBED,
|
|
222
|
+
* never assumed — is the same rule `adopteeProbeSql` (owner-adoption.ts) keeps with `rolsuper` alone.
|
|
142
223
|
*/
|
|
143
224
|
export function granterCapabilitySql(owners: DeclaredOwner[]): string {
|
|
144
225
|
const pairs = owners.map((o) => {
|
|
@@ -149,21 +230,15 @@ export function granterCapabilitySql(owners: DeclaredOwner[]): string {
|
|
|
149
230
|
return `
|
|
150
231
|
WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')}),
|
|
151
232
|
me AS (
|
|
152
|
-
SELECT b.oid,
|
|
153
|
-
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
154
|
-
WHERE m.member = b.oid AND g.rolname = 'rds_superuser'
|
|
155
|
-
)) AS is_super
|
|
233
|
+
SELECT b.oid, b.rolsuper AS is_super
|
|
156
234
|
FROM pg_roles b WHERE b.rolname = CURRENT_USER
|
|
157
235
|
)
|
|
158
236
|
SELECT
|
|
159
237
|
d.owner_name AS owner,
|
|
160
238
|
d.schema_name AS schema,
|
|
161
|
-
(me.is_super OR
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)) AS can_grant_membership,
|
|
165
|
-
(me.is_super OR n.nspowner = me.oid
|
|
166
|
-
OR has_schema_privilege(me.oid, n.oid, 'CREATE WITH GRANT OPTION')) AS can_grant_create
|
|
239
|
+
COALESCE(me.is_super OR pg_has_role(me.oid, r.oid, 'MEMBER WITH ADMIN OPTION'), false) AS can_grant_membership,
|
|
240
|
+
COALESCE(me.is_super OR n.nspowner = me.oid
|
|
241
|
+
OR has_schema_privilege(me.oid, n.oid, 'CREATE WITH GRANT OPTION'), false) AS can_grant_create
|
|
167
242
|
FROM declared d
|
|
168
243
|
CROSS JOIN me
|
|
169
244
|
LEFT JOIN pg_roles r ON r.rolname = d.owner_name
|
|
@@ -172,7 +247,7 @@ ORDER BY d.owner_name, d.schema_name
|
|
|
172
247
|
`.trim();
|
|
173
248
|
}
|
|
174
249
|
|
|
175
|
-
const keyOf = (owner: string, schema: string) => `${owner}
|
|
250
|
+
const keyOf = (owner: string, schema: string) => `${owner}\u0000${schema}`;
|
|
176
251
|
|
|
177
252
|
/**
|
|
178
253
|
* Route the two grants each (owner, schema) may need onto the connection that can run them.
|
|
@@ -181,6 +256,10 @@ const keyOf = (owner: string, schema: string) => `${owner}${schema}`;
|
|
|
181
256
|
* connection is available — then anything the operator cannot do is a refusal, never an
|
|
182
257
|
* unverified attempt on a connection that may not exist. Fable's rule: precise pre-flight
|
|
183
258
|
* refusal beats a mid-batch failure, and the master's capability is PROBED, never assumed.
|
|
259
|
+
*
|
|
260
|
+
* Each declared owner is its own UNIT (per connection), so one owner nobody can grant — refused
|
|
261
|
+
* here, before anything runs — leaves every other owner in the same run free to converge, and a
|
|
262
|
+
* failure that still reaches the server is charged to the owner that caused it.
|
|
184
263
|
*/
|
|
185
264
|
export function planOwnerEnactment(
|
|
186
265
|
operator: string,
|
|
@@ -190,9 +269,11 @@ export function planOwnerEnactment(
|
|
|
190
269
|
): EnactmentPlan {
|
|
191
270
|
const opCap = new Map(operatorCaps.map((c) => [keyOf(c.owner, c.schema), c]));
|
|
192
271
|
const mCap = masterCaps ? new Map(masterCaps.map((c) => [keyOf(c.owner, c.schema), c])) : null;
|
|
193
|
-
const
|
|
272
|
+
const b = newPlan();
|
|
273
|
+
const plan = b.plan;
|
|
194
274
|
|
|
195
275
|
for (const f of facts) {
|
|
276
|
+
const subject = `owner '${f.owner}' in schema '${f.schema}'`;
|
|
196
277
|
if (!f.roleExists) {
|
|
197
278
|
plan.refusals.push(
|
|
198
279
|
`owner role '${f.owner}' does not exist — provision never invents a security principal. ` +
|
|
@@ -206,8 +287,8 @@ export function planOwnerEnactment(
|
|
|
206
287
|
// 1. Membership: the operator must be able to SET ROLE the owner.
|
|
207
288
|
if (!f.canSetRole) {
|
|
208
289
|
const stmt = `GRANT ${f.owner} TO ${operator} WITH INHERIT FALSE, SET TRUE`;
|
|
209
|
-
if (op?.canGrantMembership)
|
|
210
|
-
else if (master?.canGrantMembership)
|
|
290
|
+
if (op?.canGrantMembership) addUnit(b, subject, 'operator', [stmt]);
|
|
291
|
+
else if (master?.canGrantMembership) addUnit(b, subject, 'master', [stmt]);
|
|
211
292
|
else plan.refusals.push(
|
|
212
293
|
`cannot GRANT ${f.owner} TO ${operator}: neither the operator nor the master holds ADMIN OPTION on '${f.owner}'. ` +
|
|
213
294
|
`Run as a role that does: ${stmt};`,
|
|
@@ -217,8 +298,8 @@ export function planOwnerEnactment(
|
|
|
217
298
|
// 2. Create: the owner must be able to CREATE in the schema the object lives in.
|
|
218
299
|
if (f.schemaExists && !f.ownerCanCreate) {
|
|
219
300
|
const stmt = `GRANT CREATE ON SCHEMA ${f.schema} TO ${f.owner}`;
|
|
220
|
-
if (op?.canGrantCreate)
|
|
221
|
-
else if (master?.canGrantCreate)
|
|
301
|
+
if (op?.canGrantCreate) addUnit(b, subject, 'operator', [stmt]);
|
|
302
|
+
else if (master?.canGrantCreate) addUnit(b, subject, 'master', [stmt]);
|
|
222
303
|
else plan.refusals.push(
|
|
223
304
|
`cannot GRANT CREATE ON SCHEMA ${f.schema} TO ${f.owner}: neither connection owns the schema or holds CREATE WITH GRANT OPTION. ` +
|
|
224
305
|
`Run as the schema owner: ${stmt};`,
|
|
@@ -229,3 +310,257 @@ export function planOwnerEnactment(
|
|
|
229
310
|
}
|
|
230
311
|
return plan;
|
|
231
312
|
}
|
|
313
|
+
|
|
314
|
+
// --- the reader's read grants — the THIRD grant kind, same probe → route → refuse machinery -----
|
|
315
|
+
//
|
|
316
|
+
// db:provision creates the SELECT-only login (`reader`, see generateReaderRoleSQL) that db:query
|
|
317
|
+
// connects as. A login with no grants reads nothing, so provision must also make the grants — and
|
|
318
|
+
// those grants are exactly as unroutable as ENACT's two: on a brownfield database the app's schema
|
|
319
|
+
// may be owned by someone the least-privilege operator cannot grant on behalf of, and the grant has
|
|
320
|
+
// to run on the master instead. Same shape as above: connection-INDEPENDENT facts (what is
|
|
321
|
+
// missing), connection-DEPENDENT capability (what THIS connection may grant), then a router.
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* What is still missing for the reader, per (schema, creator). Connection-independent — read once
|
|
325
|
+
* on either connection. `creator` is null-shaped as the empty string for a schema with no declared
|
|
326
|
+
* creating role; then only the schema-wide facts matter.
|
|
327
|
+
*/
|
|
328
|
+
export interface ReaderGrantFactRow {
|
|
329
|
+
schema: string;
|
|
330
|
+
creator: string;
|
|
331
|
+
/** The schema exists. Grants are written on what is there; an absent schema is skipped. */
|
|
332
|
+
schemaExists: boolean;
|
|
333
|
+
/** The creating role exists. An absent one gets no ALTER DEFAULT PRIVILEGES — never invented. */
|
|
334
|
+
creatorExists: boolean;
|
|
335
|
+
/** The reader lacks USAGE on the schema. */
|
|
336
|
+
usageMissing: boolean;
|
|
337
|
+
/** At least one table/view/matview/sequence in the schema is not SELECT-able by the reader. */
|
|
338
|
+
selectMissing: boolean;
|
|
339
|
+
/**
|
|
340
|
+
* The default privileges for (creator, schema) do not yet grant the reader SELECT on TABLES.
|
|
341
|
+
*
|
|
342
|
+
* TABLES and SEQUENCES are tracked separately, and deliberately so: PostgreSQL stores them as
|
|
343
|
+
* distinct `pg_default_acl` rows (`defaclobjtype` 'r' vs 'S'), and either can exist without the
|
|
344
|
+
* other — an operator who ran `ALTER DEFAULT PRIVILEGES ... ON TABLES` by hand, or a grant run
|
|
345
|
+
* that got half way. One combined flag reports "converged" as soon as EITHER row is present, so
|
|
346
|
+
* the missing one is never emitted again on any re-provision: silently, permanently unconverged,
|
|
347
|
+
* exactly where a probe-plan-converge design is supposed to self-heal.
|
|
348
|
+
*/
|
|
349
|
+
tablesDefaultsMissing: boolean;
|
|
350
|
+
/** The same, for SEQUENCES. See `tablesDefaultsMissing` for why the two are not one flag. */
|
|
351
|
+
sequencesDefaultsMissing: boolean;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** What ONE connection may grant for the reader, per (schema, creator) — read with CURRENT_USER. */
|
|
355
|
+
export interface ReaderGranterCapabilityRow {
|
|
356
|
+
schema: string;
|
|
357
|
+
creator: string;
|
|
358
|
+
/** Can `GRANT USAGE ON SCHEMA` — owns the schema, holds USAGE WITH GRANT OPTION, or is a superuser. */
|
|
359
|
+
canGrantUsage: boolean;
|
|
360
|
+
/** Can `GRANT SELECT` on EVERY relation in the schema — holds SELECT WITH GRANT OPTION on each. */
|
|
361
|
+
canGrantSelect: boolean;
|
|
362
|
+
/** Can `ALTER DEFAULT PRIVILEGES FOR ROLE <creator>` — is a member of it, or a superuser. */
|
|
363
|
+
canSetDefaults: boolean;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** `('app', 'migrator')` rows, or `('app', NULL)` for a schema with no declared creating role. */
|
|
367
|
+
function readerPairs(schemas: string[], creators: string[]): string {
|
|
368
|
+
schemas.forEach((s) => assertIdent(s, 'schema'));
|
|
369
|
+
creators.forEach((c) => assertIdent(c, 'creator'));
|
|
370
|
+
const rows: string[] = [];
|
|
371
|
+
for (const schema of schemas) {
|
|
372
|
+
if (!creators.length) rows.push(`('${schema}', NULL)`);
|
|
373
|
+
else for (const creator of creators) rows.push(`('${schema}', '${creator}')`);
|
|
374
|
+
}
|
|
375
|
+
return rows.join(', ');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The connection-independent probe: for each (schema, creator), what does the reader still lack?
|
|
380
|
+
*
|
|
381
|
+
* Every "missing" fact is read from the catalog rather than assumed, so a re-provision on a
|
|
382
|
+
* converged database emits NOTHING. That matters more here than for the owner grants: `GRANT ... ON
|
|
383
|
+
* ALL TABLES` takes an ACCESS EXCLUSIVE lock on every table it touches, so re-issuing it on each
|
|
384
|
+
* deploy would be a lock storm on a busy database for no change at all.
|
|
385
|
+
*
|
|
386
|
+
* `has_table_privilege` is used for sequences too (it accepts them); the relation list is fenced
|
|
387
|
+
* behind `OFFSET 0` because the planner may otherwise evaluate the privilege call before the
|
|
388
|
+
* relkind filter and error on a TOAST relation.
|
|
389
|
+
*
|
|
390
|
+
* THE DEFAULT PRIVILEGES ARE TWO FACTS, NOT ONE. `pg_default_acl` holds a separate row per object
|
|
391
|
+
* kind, so `defaclobjtype = 'r'` (TABLES) and `= 'S'` (SEQUENCES) get one `EXISTS` each. Asking
|
|
392
|
+
* `defaclobjtype IN ('r','S')` would report BOTH converged the moment EITHER row exists — and the
|
|
393
|
+
* missing statement would then never be planned again, because the plan is driven entirely by
|
|
394
|
+
* these facts. That is reachable with no failure at all: a brownfield operator who already ran
|
|
395
|
+
* `ALTER DEFAULT PRIVILEGES ... ON TABLES` by hand, or a prior grant run that got half way.
|
|
396
|
+
* "Converged" therefore has two independent answers per (schema, creator), and `planReaderGrants`
|
|
397
|
+
* gates the two statements separately.
|
|
398
|
+
*/
|
|
399
|
+
export function readerGrantFactsSql(reader: string, schemas: string[], creators: string[]): string {
|
|
400
|
+
assertIdent(reader, 'reader');
|
|
401
|
+
const pairs = readerPairs(schemas, creators);
|
|
402
|
+
return `
|
|
403
|
+
WITH declared(schema_name, creator) AS (VALUES ${pairs}),
|
|
404
|
+
rdr AS (SELECT oid FROM pg_roles WHERE rolname = '${reader}')
|
|
405
|
+
SELECT
|
|
406
|
+
d.schema_name AS schema,
|
|
407
|
+
COALESCE(d.creator, '') AS creator,
|
|
408
|
+
(n.oid IS NOT NULL) AS schema_exists,
|
|
409
|
+
(c.oid IS NOT NULL) AS creator_exists,
|
|
410
|
+
CASE WHEN n.oid IS NULL OR rdr.oid IS NULL THEN true
|
|
411
|
+
ELSE NOT has_schema_privilege(rdr.oid, n.oid, 'USAGE') END AS usage_missing,
|
|
412
|
+
CASE WHEN n.oid IS NULL OR rdr.oid IS NULL THEN true
|
|
413
|
+
ELSE EXISTS (
|
|
414
|
+
SELECT 1 FROM (
|
|
415
|
+
SELECT k.oid FROM pg_class k
|
|
416
|
+
WHERE k.relnamespace = n.oid AND k.relkind IN ('r','p','v','m','f','S')
|
|
417
|
+
OFFSET 0
|
|
418
|
+
) rel
|
|
419
|
+
WHERE NOT has_table_privilege(rdr.oid, rel.oid, 'SELECT')
|
|
420
|
+
) END AS select_missing,
|
|
421
|
+
CASE WHEN n.oid IS NULL OR c.oid IS NULL OR rdr.oid IS NULL THEN true
|
|
422
|
+
ELSE NOT EXISTS (
|
|
423
|
+
SELECT 1 FROM pg_default_acl da, aclexplode(da.defaclacl) a
|
|
424
|
+
WHERE da.defaclrole = c.oid AND da.defaclnamespace = n.oid
|
|
425
|
+
AND da.defaclobjtype = 'r' AND a.grantee = rdr.oid AND a.privilege_type = 'SELECT'
|
|
426
|
+
) END AS tables_defaults_missing,
|
|
427
|
+
CASE WHEN n.oid IS NULL OR c.oid IS NULL OR rdr.oid IS NULL THEN true
|
|
428
|
+
ELSE NOT EXISTS (
|
|
429
|
+
SELECT 1 FROM pg_default_acl da, aclexplode(da.defaclacl) a
|
|
430
|
+
WHERE da.defaclrole = c.oid AND da.defaclnamespace = n.oid
|
|
431
|
+
AND da.defaclobjtype = 'S' AND a.grantee = rdr.oid AND a.privilege_type = 'SELECT'
|
|
432
|
+
) END AS sequences_defaults_missing
|
|
433
|
+
FROM declared d
|
|
434
|
+
CROSS JOIN rdr
|
|
435
|
+
LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
|
|
436
|
+
LEFT JOIN pg_roles c ON c.rolname = d.creator
|
|
437
|
+
ORDER BY d.schema_name, d.creator
|
|
438
|
+
`.trim();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The connection-DEPENDENT probe: what may CURRENT_USER grant for the reader?
|
|
443
|
+
*
|
|
444
|
+
* `GRANT ... ON ALL TABLES` run by a role that lacks the grant option on some relations does NOT
|
|
445
|
+
* error — it emits a warning and silently skips them. So capability is probed per schema BEFORE
|
|
446
|
+
* anything runs, and a schema the connection cannot fully cover is routed to the master or refused.
|
|
447
|
+
* A partial grant that looks like success is the failure mode this probe exists to prevent.
|
|
448
|
+
*
|
|
449
|
+
* `rolsuper` alone means superuser here — membership in `rds_superuser` does NOT, for exactly the
|
|
450
|
+
* reason spelled out on `granterCapabilitySql`. On RDS that shortcut claimed the master could grant
|
|
451
|
+
* SELECT on a table a DBA owns and it cannot; the honest reads below (which follow inherited ACLs,
|
|
452
|
+
* including anything `rds_superuser` membership really carries) route it to a refusal instead.
|
|
453
|
+
*/
|
|
454
|
+
export function readerGrantCapabilitySql(schemas: string[], creators: string[]): string {
|
|
455
|
+
const pairs = readerPairs(schemas, creators);
|
|
456
|
+
return `
|
|
457
|
+
WITH declared(schema_name, creator) AS (VALUES ${pairs}),
|
|
458
|
+
me AS (
|
|
459
|
+
SELECT b.oid, b.rolsuper AS is_super
|
|
460
|
+
FROM pg_roles b WHERE b.rolname = CURRENT_USER
|
|
461
|
+
)
|
|
462
|
+
SELECT
|
|
463
|
+
d.schema_name AS schema,
|
|
464
|
+
COALESCE(d.creator, '') AS creator,
|
|
465
|
+
(me.is_super OR (n.oid IS NOT NULL AND (n.nspowner = me.oid
|
|
466
|
+
OR has_schema_privilege(me.oid, n.oid, 'USAGE WITH GRANT OPTION')))) AS can_grant_usage,
|
|
467
|
+
(me.is_super OR (n.oid IS NOT NULL AND NOT EXISTS (
|
|
468
|
+
SELECT 1 FROM (
|
|
469
|
+
SELECT k.oid FROM pg_class k
|
|
470
|
+
WHERE k.relnamespace = n.oid AND k.relkind IN ('r','p','v','m','f','S')
|
|
471
|
+
OFFSET 0
|
|
472
|
+
) rel
|
|
473
|
+
WHERE NOT has_table_privilege(me.oid, rel.oid, 'SELECT WITH GRANT OPTION')
|
|
474
|
+
))) AS can_grant_select,
|
|
475
|
+
(me.is_super OR (c.oid IS NOT NULL AND pg_has_role(me.oid, c.oid, 'USAGE'))) AS can_set_defaults
|
|
476
|
+
FROM declared d
|
|
477
|
+
CROSS JOIN me
|
|
478
|
+
LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
|
|
479
|
+
LEFT JOIN pg_roles c ON c.rolname = d.creator
|
|
480
|
+
ORDER BY d.schema_name, d.creator
|
|
481
|
+
`.trim();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Route the reader's read grants onto the connection that can run each — the third grant kind,
|
|
486
|
+
* decided the same way as ENACT's two.
|
|
487
|
+
*
|
|
488
|
+
* Emits a statement ONLY when its fact says it is missing (idempotence by construction), and only
|
|
489
|
+
* when some connection can actually make it. Where neither can, the refusal carries the runnable
|
|
490
|
+
* SQL — the same contract as planOwnerEnactment, so an operator who must run one grant as the
|
|
491
|
+
* schema owner can copy it verbatim. `masterCaps` is null when there is no master connection: then
|
|
492
|
+
* anything the operator cannot do is a refusal, never an attempt on a connection that may not exist.
|
|
493
|
+
*
|
|
494
|
+
* A schema may appear in several rows (one per creating role); the schema-wide USAGE and SELECT
|
|
495
|
+
* grants are considered once, on its first row. That is safe because both capabilities are read
|
|
496
|
+
* from the SCHEMA alone (`readerGrantCapabilitySql`), so every row of a schema carries the same
|
|
497
|
+
* answer — only `canSetDefaults` varies per creating role.
|
|
498
|
+
*/
|
|
499
|
+
export function planReaderGrants(
|
|
500
|
+
reader: string,
|
|
501
|
+
facts: ReaderGrantFactRow[],
|
|
502
|
+
operatorCaps: ReaderGranterCapabilityRow[],
|
|
503
|
+
masterCaps: ReaderGranterCapabilityRow[] | null,
|
|
504
|
+
): EnactmentPlan {
|
|
505
|
+
// keyOf is the generic two-part key; here the pair is (schema, creator), not (owner, schema).
|
|
506
|
+
const opCap = new Map(operatorCaps.map((c) => [keyOf(c.schema, c.creator), c]));
|
|
507
|
+
const mCap = masterCaps ? new Map(masterCaps.map((c) => [keyOf(c.schema, c.creator), c])) : null;
|
|
508
|
+
const b = newPlan();
|
|
509
|
+
const plan = b.plan;
|
|
510
|
+
const schemaDone = new Set<string>();
|
|
511
|
+
|
|
512
|
+
// Each grant kind is its own unit — the same grain as ENACT's per-owner units, and for the same
|
|
513
|
+
// reason: a schema whose tables a DBA owns must not cost the reader its grants everywhere ELSE.
|
|
514
|
+
const route = (
|
|
515
|
+
subject: string,
|
|
516
|
+
statements: string[],
|
|
517
|
+
can: (c: ReaderGranterCapabilityRow) => boolean,
|
|
518
|
+
op: ReaderGranterCapabilityRow | undefined,
|
|
519
|
+
master: ReaderGranterCapabilityRow | null,
|
|
520
|
+
refusal: (fix: string) => string,
|
|
521
|
+
): void => {
|
|
522
|
+
if (op && can(op)) addUnit(b, subject, 'operator', statements);
|
|
523
|
+
else if (master && can(master)) addUnit(b, subject, 'master', statements);
|
|
524
|
+
else plan.refusals.push(refusal(statements.map((s) => `${s};`).join(' ')));
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
for (const f of facts) {
|
|
528
|
+
const op = opCap.get(keyOf(f.schema, f.creator));
|
|
529
|
+
const master = mCap?.get(keyOf(f.schema, f.creator)) ?? null;
|
|
530
|
+
if (!f.schemaExists) continue; // grants are written on what exists — see planOwnerEnactment
|
|
531
|
+
|
|
532
|
+
const [grants] = generateReaderGrantSQL({ readerRole: reader, schemas: [f.schema], creators: f.creator ? [f.creator] : [] });
|
|
533
|
+
|
|
534
|
+
if (!schemaDone.has(f.schema)) {
|
|
535
|
+
schemaDone.add(f.schema);
|
|
536
|
+
if (f.usageMissing) {
|
|
537
|
+
route(`the reader's USAGE on schema '${f.schema}'`, [grants.usage], (c) => c.canGrantUsage, op, master, (fix) =>
|
|
538
|
+
`cannot GRANT USAGE ON SCHEMA ${f.schema} TO ${reader}: neither connection owns the schema or holds USAGE WITH GRANT OPTION. ` +
|
|
539
|
+
`Run as the schema owner: ${fix}`);
|
|
540
|
+
}
|
|
541
|
+
if (f.selectMissing) {
|
|
542
|
+
route(`the reader's SELECT in schema '${f.schema}'`, grants.select, (c) => c.canGrantSelect, op, master, (fix) =>
|
|
543
|
+
`cannot GRANT SELECT in schema ${f.schema} TO ${reader}: neither connection holds SELECT WITH GRANT OPTION on every relation there. ` +
|
|
544
|
+
`Run as their owner: ${fix}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// TABLES and SEQUENCES are gated one by one, never as a pair: they are separate catalog rows
|
|
549
|
+
// and either can already be in place (see ReaderGrantFactRow.tablesDefaultsMissing). Only the
|
|
550
|
+
// kinds actually missing are emitted, so a converged database still plans nothing.
|
|
551
|
+
if (f.creator && f.creatorExists) {
|
|
552
|
+
const [def] = grants.defaults;
|
|
553
|
+
const missing: string[] = [];
|
|
554
|
+
if (f.tablesDefaultsMissing) missing.push(def.tables);
|
|
555
|
+
if (f.sequencesDefaultsMissing) missing.push(def.sequences);
|
|
556
|
+
if (missing.length) {
|
|
557
|
+
route(`the reader's default privileges for role '${f.creator}' in schema '${f.schema}'`,
|
|
558
|
+
missing, (c) => c.canSetDefaults, op, master, (fix) =>
|
|
559
|
+
`cannot ALTER DEFAULT PRIVILEGES FOR ROLE ${f.creator} IN SCHEMA ${f.schema}: neither connection is a member of '${f.creator}'. ` +
|
|
560
|
+
`Without it, a table created later by '${f.creator}' is invisible to ${reader} until the next provision. ` +
|
|
561
|
+
`Run as a member of it: ${fix}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return plan;
|
|
566
|
+
}
|