postgresai 0.14.0-dev.10 → 0.14.0-dev.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/bin/postgres-ai.ts +160 -29
- package/dist/bin/postgres-ai.js +148 -26
- package/dist/bin/postgres-ai.js.map +1 -1
- package/dist/lib/init.d.ts +11 -0
- package/dist/lib/init.d.ts.map +1 -1
- package/dist/lib/init.js +106 -12
- package/dist/lib/init.js.map +1 -1
- package/dist/package.json +1 -1
- package/lib/init.ts +142 -12
- package/package.json +1 -1
- package/sql/01.role.sql +11 -0
- package/test/init.integration.test.cjs +86 -0
- package/test/init.test.cjs +30 -0
package/lib/init.ts
CHANGED
|
@@ -340,6 +340,7 @@ export async function buildInitPlan(params: {
|
|
|
340
340
|
const qRole = quoteIdent(monitoringUser);
|
|
341
341
|
const qDb = quoteIdent(database);
|
|
342
342
|
const qPw = quoteLiteral(params.monitoringPassword);
|
|
343
|
+
const qRoleNameLit = quoteLiteral(monitoringUser);
|
|
343
344
|
|
|
344
345
|
const steps: InitStep[] = [];
|
|
345
346
|
|
|
@@ -348,21 +349,26 @@ export async function buildInitPlan(params: {
|
|
|
348
349
|
DB_IDENT: qDb,
|
|
349
350
|
};
|
|
350
351
|
|
|
351
|
-
// Role creation/update is done in one template file
|
|
352
|
+
// Role creation/update is done in one template file.
|
|
353
|
+
// If roleExists is unknown, use a single DO block to create-or-alter safely.
|
|
354
|
+
let roleStmt: string | null = null;
|
|
352
355
|
if (params.roleExists === false) {
|
|
353
|
-
|
|
354
|
-
...vars,
|
|
355
|
-
ROLE_STMT: `create user ${qRole} with password ${qPw};`,
|
|
356
|
-
});
|
|
357
|
-
steps.push({ name: "01.role", sql: roleSql });
|
|
356
|
+
roleStmt = `create user ${qRole} with password ${qPw};`;
|
|
358
357
|
} else if (params.roleExists === true) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
358
|
+
roleStmt = `alter user ${qRole} with password ${qPw};`;
|
|
359
|
+
} else {
|
|
360
|
+
roleStmt = `do $$ begin
|
|
361
|
+
if not exists (select 1 from pg_catalog.pg_roles where rolname = ${qRoleNameLit}) then
|
|
362
|
+
create user ${qRole} with password ${qPw};
|
|
363
|
+
else
|
|
364
|
+
alter user ${qRole} with password ${qPw};
|
|
365
|
+
end if;
|
|
366
|
+
end $$;`;
|
|
364
367
|
}
|
|
365
368
|
|
|
369
|
+
const roleSql = applyTemplate(loadSqlTemplate("01.role.sql"), { ...vars, ROLE_STMT: roleStmt });
|
|
370
|
+
steps.push({ name: "01.role", sql: roleSql });
|
|
371
|
+
|
|
366
372
|
steps.push({
|
|
367
373
|
name: "02.permissions",
|
|
368
374
|
sql: applyTemplate(loadSqlTemplate("02.permissions.sql"), vars),
|
|
@@ -414,7 +420,12 @@ export async function applyInitPlan(params: {
|
|
|
414
420
|
}
|
|
415
421
|
await params.client.query("commit;");
|
|
416
422
|
} catch (e) {
|
|
417
|
-
|
|
423
|
+
// Rollback errors should never mask the original failure.
|
|
424
|
+
try {
|
|
425
|
+
await params.client.query("rollback;");
|
|
426
|
+
} catch {
|
|
427
|
+
// ignore
|
|
428
|
+
}
|
|
418
429
|
throw e;
|
|
419
430
|
}
|
|
420
431
|
|
|
@@ -432,4 +443,123 @@ export async function applyInitPlan(params: {
|
|
|
432
443
|
return { applied, skippedOptional };
|
|
433
444
|
}
|
|
434
445
|
|
|
446
|
+
export type VerifyInitResult = {
|
|
447
|
+
ok: boolean;
|
|
448
|
+
missingRequired: string[];
|
|
449
|
+
missingOptional: string[];
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export async function verifyInitSetup(params: {
|
|
453
|
+
client: PgClient;
|
|
454
|
+
database: string;
|
|
455
|
+
monitoringUser: string;
|
|
456
|
+
includeOptionalPermissions: boolean;
|
|
457
|
+
}): Promise<VerifyInitResult> {
|
|
458
|
+
const missingRequired: string[] = [];
|
|
459
|
+
const missingOptional: string[] = [];
|
|
460
|
+
|
|
461
|
+
const role = params.monitoringUser;
|
|
462
|
+
const db = params.database;
|
|
463
|
+
|
|
464
|
+
const roleRes = await params.client.query("select 1 from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
465
|
+
const roleExists = (roleRes.rowCount ?? 0) > 0;
|
|
466
|
+
if (!roleExists) {
|
|
467
|
+
missingRequired.push(`role "${role}" does not exist`);
|
|
468
|
+
// If role is missing, other checks will error or be meaningless.
|
|
469
|
+
return { ok: false, missingRequired, missingOptional };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const connectRes = await params.client.query(
|
|
473
|
+
"select has_database_privilege($1, $2, 'CONNECT') as ok",
|
|
474
|
+
[role, db]
|
|
475
|
+
);
|
|
476
|
+
if (!connectRes.rows?.[0]?.ok) {
|
|
477
|
+
missingRequired.push(`CONNECT on database "${db}"`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const pgMonitorRes = await params.client.query(
|
|
481
|
+
"select pg_has_role($1, 'pg_monitor', 'member') as ok",
|
|
482
|
+
[role]
|
|
483
|
+
);
|
|
484
|
+
if (!pgMonitorRes.rows?.[0]?.ok) {
|
|
485
|
+
missingRequired.push("membership in role pg_monitor");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const pgIndexRes = await params.client.query(
|
|
489
|
+
"select has_table_privilege($1, 'pg_catalog.pg_index', 'SELECT') as ok",
|
|
490
|
+
[role]
|
|
491
|
+
);
|
|
492
|
+
if (!pgIndexRes.rows?.[0]?.ok) {
|
|
493
|
+
missingRequired.push("SELECT on pg_catalog.pg_index");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const viewExistsRes = await params.client.query("select to_regclass('public.pg_statistic') is not null as ok");
|
|
497
|
+
if (!viewExistsRes.rows?.[0]?.ok) {
|
|
498
|
+
missingRequired.push("view public.pg_statistic exists");
|
|
499
|
+
} else {
|
|
500
|
+
const viewPrivRes = await params.client.query(
|
|
501
|
+
"select has_table_privilege($1, 'public.pg_statistic', 'SELECT') as ok",
|
|
502
|
+
[role]
|
|
503
|
+
);
|
|
504
|
+
if (!viewPrivRes.rows?.[0]?.ok) {
|
|
505
|
+
missingRequired.push("SELECT on view public.pg_statistic");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const schemaUsageRes = await params.client.query(
|
|
510
|
+
"select has_schema_privilege($1, 'public', 'USAGE') as ok",
|
|
511
|
+
[role]
|
|
512
|
+
);
|
|
513
|
+
if (!schemaUsageRes.rows?.[0]?.ok) {
|
|
514
|
+
missingRequired.push("USAGE on schema public");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
518
|
+
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
|
|
519
|
+
const spLine = Array.isArray(rolconfig) ? rolconfig.find((v: any) => String(v).startsWith("search_path=")) : undefined;
|
|
520
|
+
if (typeof spLine !== "string" || !spLine) {
|
|
521
|
+
missingRequired.push("role search_path is set");
|
|
522
|
+
} else {
|
|
523
|
+
// We accept any ordering as long as public and pg_catalog are included.
|
|
524
|
+
const sp = spLine.toLowerCase();
|
|
525
|
+
if (!sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
526
|
+
missingRequired.push("role search_path includes public and pg_catalog");
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (params.includeOptionalPermissions) {
|
|
531
|
+
// Optional RDS/Aurora extras
|
|
532
|
+
{
|
|
533
|
+
const extRes = await params.client.query("select 1 from pg_extension where extname = 'rds_tools'");
|
|
534
|
+
if ((extRes.rowCount ?? 0) === 0) {
|
|
535
|
+
missingOptional.push("extension rds_tools");
|
|
536
|
+
} else {
|
|
537
|
+
const fnRes = await params.client.query(
|
|
538
|
+
"select has_function_privilege($1, 'rds_tools.pg_ls_multixactdir()', 'EXECUTE') as ok",
|
|
539
|
+
[role]
|
|
540
|
+
);
|
|
541
|
+
if (!fnRes.rows?.[0]?.ok) {
|
|
542
|
+
missingOptional.push("EXECUTE on rds_tools.pg_ls_multixactdir()");
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Optional self-managed extras
|
|
548
|
+
const optionalFns = [
|
|
549
|
+
"pg_catalog.pg_stat_file(text)",
|
|
550
|
+
"pg_catalog.pg_stat_file(text, boolean)",
|
|
551
|
+
"pg_catalog.pg_ls_dir(text)",
|
|
552
|
+
"pg_catalog.pg_ls_dir(text, boolean, boolean)",
|
|
553
|
+
];
|
|
554
|
+
for (const fn of optionalFns) {
|
|
555
|
+
const fnRes = await params.client.query("select has_function_privilege($1, $2, 'EXECUTE') as ok", [role, fn]);
|
|
556
|
+
if (!fnRes.rows?.[0]?.ok) {
|
|
557
|
+
missingOptional.push(`EXECUTE on ${fn}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return { ok: missingRequired.length === 0, missingRequired, missingOptional };
|
|
563
|
+
}
|
|
564
|
+
|
|
435
565
|
|
package/package.json
CHANGED
package/sql/01.role.sql
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
-- Role creation / password update (template-filled by cli/lib/init.ts)
|
|
2
|
+
--
|
|
3
|
+
-- Example expansions (for readability/review):
|
|
4
|
+
-- create user "postgres_ai_mon" with password '...';
|
|
5
|
+
-- alter user "postgres_ai_mon" with password '...';
|
|
6
|
+
-- do $$ begin
|
|
7
|
+
-- if not exists (select 1 from pg_catalog.pg_roles where rolname = 'postgres_ai_mon') then
|
|
8
|
+
-- create user "postgres_ai_mon" with password '...';
|
|
9
|
+
-- else
|
|
10
|
+
-- alter user "postgres_ai_mon" with password '...';
|
|
11
|
+
-- end if;
|
|
12
|
+
-- end $$;
|
|
2
13
|
{{ROLE_STMT}}
|
|
3
14
|
|
|
4
15
|
|
|
@@ -190,6 +190,29 @@ test(
|
|
|
190
190
|
}
|
|
191
191
|
);
|
|
192
192
|
|
|
193
|
+
test(
|
|
194
|
+
"integration: init requires explicit monitoring password in non-interactive mode (unless --print-password)",
|
|
195
|
+
{ skip: !havePostgresBinaries() },
|
|
196
|
+
async (t) => {
|
|
197
|
+
const pg = await withTempPostgres(t);
|
|
198
|
+
|
|
199
|
+
// spawnSync captures stdout/stderr (non-TTY). We should not print a generated password unless explicitly requested.
|
|
200
|
+
{
|
|
201
|
+
const r = await runCliInit([pg.adminUri, "--skip-optional-permissions"]);
|
|
202
|
+
assert.notEqual(r.status, 0);
|
|
203
|
+
assert.match(r.stderr, /not printed in non-interactive mode/i);
|
|
204
|
+
assert.match(r.stderr, /--print-password/);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// With explicit opt-in, it should succeed (and will print the generated password).
|
|
208
|
+
{
|
|
209
|
+
const r = await runCliInit([pg.adminUri, "--print-password", "--skip-optional-permissions"]);
|
|
210
|
+
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
211
|
+
assert.match(r.stdout, /Generated password for monitoring user/i);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
);
|
|
215
|
+
|
|
193
216
|
test(
|
|
194
217
|
"integration: init fixes slightly-off permissions idempotently",
|
|
195
218
|
{ skip: !havePostgresBinaries() },
|
|
@@ -276,4 +299,67 @@ test("integration: init reports nicely when lacking permissions", { skip: !haveP
|
|
|
276
299
|
assert.match(r.stderr, /Hint: connect as a superuser/i);
|
|
277
300
|
});
|
|
278
301
|
|
|
302
|
+
test("integration: init --verify returns 0 when ok and non-zero when missing", { skip: !havePostgresBinaries() }, async (t) => {
|
|
303
|
+
const pg = await withTempPostgres(t);
|
|
304
|
+
const { Client } = require("pg");
|
|
305
|
+
|
|
306
|
+
// Prepare: run init
|
|
307
|
+
{
|
|
308
|
+
const r = await runCliInit([pg.adminUri, "--password", "monpw", "--skip-optional-permissions"]);
|
|
309
|
+
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Verify should pass
|
|
313
|
+
{
|
|
314
|
+
const r = await runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
|
|
315
|
+
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
316
|
+
assert.match(r.stdout, /init verify: OK/i);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Break a required privilege and ensure verify fails
|
|
320
|
+
{
|
|
321
|
+
const c = new Client({ connectionString: pg.adminUri });
|
|
322
|
+
await c.connect();
|
|
323
|
+
// pg_catalog tables are often readable via PUBLIC by default; revoke from PUBLIC too so the failure is deterministic.
|
|
324
|
+
await c.query("revoke select on pg_catalog.pg_index from public");
|
|
325
|
+
await c.query("revoke select on pg_catalog.pg_index from postgres_ai_mon");
|
|
326
|
+
await c.end();
|
|
327
|
+
}
|
|
328
|
+
{
|
|
329
|
+
const r = await runCliInit([pg.adminUri, "--verify", "--skip-optional-permissions"]);
|
|
330
|
+
assert.notEqual(r.status, 0);
|
|
331
|
+
assert.match(r.stderr, /init verify failed/i);
|
|
332
|
+
assert.match(r.stderr, /pg_catalog\.pg_index/i);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("integration: init --reset-password updates the monitoring role login password", { skip: !havePostgresBinaries() }, async (t) => {
|
|
337
|
+
const pg = await withTempPostgres(t);
|
|
338
|
+
const { Client } = require("pg");
|
|
339
|
+
|
|
340
|
+
// Initial setup with password pw1
|
|
341
|
+
{
|
|
342
|
+
const r = await runCliInit([pg.adminUri, "--password", "pw1", "--skip-optional-permissions"]);
|
|
343
|
+
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Reset to pw2
|
|
347
|
+
{
|
|
348
|
+
const r = await runCliInit([pg.adminUri, "--reset-password", "--password", "pw2", "--skip-optional-permissions"]);
|
|
349
|
+
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
350
|
+
assert.match(r.stdout, /password reset/i);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Connect as monitoring user with new password should work
|
|
354
|
+
{
|
|
355
|
+
const c = new Client({
|
|
356
|
+
connectionString: `postgresql://postgres_ai_mon:pw2@127.0.0.1:${pg.port}/testdb`,
|
|
357
|
+
});
|
|
358
|
+
await c.connect();
|
|
359
|
+
const ok = await c.query("select 1 as ok");
|
|
360
|
+
assert.equal(ok.rows[0].ok, 1);
|
|
361
|
+
await c.end();
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
279
365
|
|
package/test/init.test.cjs
CHANGED
|
@@ -5,6 +5,17 @@ const assert = require("node:assert/strict");
|
|
|
5
5
|
// Run via: npm --prefix cli test
|
|
6
6
|
const init = require("../dist/lib/init.js");
|
|
7
7
|
|
|
8
|
+
function runCli(args, env = {}) {
|
|
9
|
+
const { spawnSync } = require("node:child_process");
|
|
10
|
+
const path = require("node:path");
|
|
11
|
+
const node = process.execPath;
|
|
12
|
+
const cliPath = path.resolve(__dirname, "..", "dist", "bin", "postgres-ai.js");
|
|
13
|
+
return spawnSync(node, [cliPath, ...args], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
env: { ...process.env, ...env },
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
test("maskConnectionString hides password when present", () => {
|
|
9
20
|
const masked = init.maskConnectionString("postgresql://user:secret@localhost:5432/mydb");
|
|
10
21
|
assert.match(masked, /postgresql:\/\/user:\*{5}@localhost:5432\/mydb/);
|
|
@@ -42,6 +53,18 @@ test("buildInitPlan includes create user when role does not exist", async () =>
|
|
|
42
53
|
assert.ok(!plan.steps.some((s) => s.optional));
|
|
43
54
|
});
|
|
44
55
|
|
|
56
|
+
test("buildInitPlan includes role step when roleExists is omitted", async () => {
|
|
57
|
+
const plan = await init.buildInitPlan({
|
|
58
|
+
database: "mydb",
|
|
59
|
+
monitoringUser: "postgres_ai_mon",
|
|
60
|
+
monitoringPassword: "pw",
|
|
61
|
+
includeOptionalPermissions: false,
|
|
62
|
+
});
|
|
63
|
+
const roleStep = plan.steps.find((s) => s.name === "01.role");
|
|
64
|
+
assert.ok(roleStep);
|
|
65
|
+
assert.match(roleStep.sql, /do\s+\$\$/i);
|
|
66
|
+
});
|
|
67
|
+
|
|
45
68
|
test("buildInitPlan inlines password safely for CREATE/ALTER ROLE grammar", async () => {
|
|
46
69
|
const plan = await init.buildInitPlan({
|
|
47
70
|
database: "mydb",
|
|
@@ -113,4 +136,11 @@ test("print-sql redaction regex matches password literal with embedded quotes",
|
|
|
113
136
|
assert.match(redacted, /password '<redacted>'/i);
|
|
114
137
|
});
|
|
115
138
|
|
|
139
|
+
test("cli: init --print-sql works without connection (offline mode)", () => {
|
|
140
|
+
const r = runCli(["init", "--print-sql", "-d", "mydb", "--password", "monpw"]);
|
|
141
|
+
assert.equal(r.status, 0, r.stderr || r.stdout);
|
|
142
|
+
assert.match(r.stdout, /SQL plan \(offline; not connected\)/);
|
|
143
|
+
assert.match(r.stdout, /grant connect on database "mydb" to "postgres_ai_mon"/i);
|
|
144
|
+
});
|
|
145
|
+
|
|
116
146
|
|