postgresai 0.14.0-dev.85 → 0.14.0-dev.87
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/bin/postgres-ai.ts +171 -12
- package/dist/bin/postgres-ai.js +386 -23
- package/dist/sql/03.permissions.sql +41 -2
- package/dist/sql/sql/03.permissions.sql +41 -2
- package/lib/checkup-api.ts +47 -1
- package/lib/checkup-summary.ts +283 -0
- package/lib/init.ts +31 -10
- package/package.json +1 -1
- package/sql/03.permissions.sql +41 -2
- package/test/checkup.integration.test.ts +27 -0
- package/test/checkup.test.ts +580 -1
- package/test/init.test.ts +338 -1
package/test/init.test.ts
CHANGED
|
@@ -281,7 +281,7 @@ describe("init module", () => {
|
|
|
281
281
|
return { rowCount: 1, rows: [] };
|
|
282
282
|
}
|
|
283
283
|
if (String(sql).includes("select rolconfig")) {
|
|
284
|
-
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
|
|
284
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
|
|
285
285
|
}
|
|
286
286
|
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
287
287
|
return { rowCount: 1, rows: [] };
|
|
@@ -307,6 +307,10 @@ describe("init module", () => {
|
|
|
307
307
|
if (String(sql).includes("has_schema_privilege")) {
|
|
308
308
|
return { rowCount: 1, rows: [{ ok: true }] };
|
|
309
309
|
}
|
|
310
|
+
// Query for pg_stat_statements extension schema location
|
|
311
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
312
|
+
return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
|
|
313
|
+
}
|
|
310
314
|
|
|
311
315
|
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
312
316
|
},
|
|
@@ -363,6 +367,10 @@ describe("init module", () => {
|
|
|
363
367
|
if (String(sql).includes("has_schema_privilege")) {
|
|
364
368
|
return { rowCount: 1, rows: [{ ok: true }] };
|
|
365
369
|
}
|
|
370
|
+
// Query for pg_stat_statements extension schema location (Supabase uses 'extensions' schema)
|
|
371
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
372
|
+
return { rowCount: 1, rows: [{ schema: "extensions" }] };
|
|
373
|
+
}
|
|
366
374
|
|
|
367
375
|
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
368
376
|
},
|
|
@@ -382,6 +390,335 @@ describe("init module", () => {
|
|
|
382
390
|
expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
|
|
383
391
|
});
|
|
384
392
|
|
|
393
|
+
test("verifyInitSetup checks extensions schema when pg_stat_statements is there", async () => {
|
|
394
|
+
const calls: string[] = [];
|
|
395
|
+
const client = {
|
|
396
|
+
query: async (sql: string, params?: any) => {
|
|
397
|
+
calls.push(String(sql));
|
|
398
|
+
|
|
399
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
400
|
+
return { rowCount: 1, rows: [] };
|
|
401
|
+
}
|
|
402
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
403
|
+
return { rowCount: 1, rows: [] };
|
|
404
|
+
}
|
|
405
|
+
if (String(sql).includes("select rolconfig")) {
|
|
406
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
|
|
407
|
+
}
|
|
408
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
409
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
410
|
+
}
|
|
411
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
412
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
413
|
+
}
|
|
414
|
+
if (String(sql).includes("pg_has_role")) {
|
|
415
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
416
|
+
}
|
|
417
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
418
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
419
|
+
}
|
|
420
|
+
if (String(sql).includes("to_regclass")) {
|
|
421
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
422
|
+
}
|
|
423
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
424
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
425
|
+
}
|
|
426
|
+
// pg_stat_statements is in 'extensions' schema
|
|
427
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
428
|
+
return { rowCount: 1, rows: [{ schema: "extensions" }] };
|
|
429
|
+
}
|
|
430
|
+
// Check for USAGE on extensions schema
|
|
431
|
+
if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
|
|
432
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
433
|
+
}
|
|
434
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
435
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const r = await init.verifyInitSetup({
|
|
443
|
+
client: client as any,
|
|
444
|
+
database: "mydb",
|
|
445
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
446
|
+
includeOptionalPermissions: false,
|
|
447
|
+
});
|
|
448
|
+
expect(r.ok).toBe(true);
|
|
449
|
+
expect(r.missingRequired.length).toBe(0);
|
|
450
|
+
// Should have queried for pg_stat_statements schema location
|
|
451
|
+
expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("verifyInitSetup reports missing extensions schema access", async () => {
|
|
455
|
+
const client = {
|
|
456
|
+
query: async (sql: string, params?: any) => {
|
|
457
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
458
|
+
return { rowCount: 1, rows: [] };
|
|
459
|
+
}
|
|
460
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
461
|
+
return { rowCount: 1, rows: [] };
|
|
462
|
+
}
|
|
463
|
+
if (String(sql).includes("select rolconfig")) {
|
|
464
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
|
|
465
|
+
}
|
|
466
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
467
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
468
|
+
}
|
|
469
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
470
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
471
|
+
}
|
|
472
|
+
if (String(sql).includes("pg_has_role")) {
|
|
473
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
474
|
+
}
|
|
475
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
476
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
477
|
+
}
|
|
478
|
+
if (String(sql).includes("to_regclass")) {
|
|
479
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
480
|
+
}
|
|
481
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
482
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
483
|
+
}
|
|
484
|
+
// pg_stat_statements is in 'extensions' schema
|
|
485
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
486
|
+
return { rowCount: 1, rows: [{ schema: "extensions" }] };
|
|
487
|
+
}
|
|
488
|
+
// No USAGE on extensions schema
|
|
489
|
+
if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
|
|
490
|
+
return { rowCount: 1, rows: [{ ok: false }] };
|
|
491
|
+
}
|
|
492
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
493
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const r = await init.verifyInitSetup({
|
|
501
|
+
client: client as any,
|
|
502
|
+
database: "mydb",
|
|
503
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
504
|
+
includeOptionalPermissions: false,
|
|
505
|
+
});
|
|
506
|
+
expect(r.ok).toBe(false);
|
|
507
|
+
// Should report missing USAGE on extensions schema
|
|
508
|
+
expect(r.missingRequired.some((m) => m.includes("extensions") && m.includes("pg_stat_statements"))).toBe(true);
|
|
509
|
+
// Should also report missing extensions in search_path
|
|
510
|
+
expect(r.missingRequired.some((m) => m.includes("search_path") && m.includes("extensions"))).toBe(true);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("buildInitPlan includes dynamic search_path with extension schema detection", async () => {
|
|
514
|
+
const plan = await init.buildInitPlan({
|
|
515
|
+
database: "mydb",
|
|
516
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
517
|
+
monitoringPassword: "pw",
|
|
518
|
+
includeOptionalPermissions: false,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const permStep = plan.steps.find((s) => s.name === "03.permissions");
|
|
522
|
+
expect(permStep).toBeTruthy();
|
|
523
|
+
// Should use dynamic DO block to set search_path based on detected extension schema
|
|
524
|
+
expect(permStep!.sql).toMatch(/alter\s+user.*set\s+search_path\s*=/i);
|
|
525
|
+
// Should detect pg_stat_statements extension schema dynamically
|
|
526
|
+
expect(permStep!.sql).toMatch(/quote_ident\(ext_schema\)/i);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("buildInitPlan includes dynamic extension schema grant", async () => {
|
|
530
|
+
const plan = await init.buildInitPlan({
|
|
531
|
+
database: "mydb",
|
|
532
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
533
|
+
monitoringPassword: "pw",
|
|
534
|
+
includeOptionalPermissions: false,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const permStep = plan.steps.find((s) => s.name === "03.permissions");
|
|
538
|
+
expect(permStep).toBeTruthy();
|
|
539
|
+
// Should include DO block that grants USAGE on extension schema
|
|
540
|
+
expect(permStep!.sql).toMatch(/do\s+\$\$/i);
|
|
541
|
+
expect(permStep!.sql).toMatch(/pg_stat_statements/);
|
|
542
|
+
expect(permStep!.sql).toMatch(/grant usage on schema/i);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("verifyInitSetup handles pg_stat_statements not installed", async () => {
|
|
546
|
+
const calls: string[] = [];
|
|
547
|
+
const client = {
|
|
548
|
+
query: async (sql: string, params?: any) => {
|
|
549
|
+
calls.push(String(sql));
|
|
550
|
+
|
|
551
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
552
|
+
return { rowCount: 1, rows: [] };
|
|
553
|
+
}
|
|
554
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
555
|
+
return { rowCount: 1, rows: [] };
|
|
556
|
+
}
|
|
557
|
+
if (String(sql).includes("select rolconfig")) {
|
|
558
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
|
|
559
|
+
}
|
|
560
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
561
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
562
|
+
}
|
|
563
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
564
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
565
|
+
}
|
|
566
|
+
if (String(sql).includes("pg_has_role")) {
|
|
567
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
568
|
+
}
|
|
569
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
570
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
571
|
+
}
|
|
572
|
+
if (String(sql).includes("to_regclass")) {
|
|
573
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
574
|
+
}
|
|
575
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
576
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
577
|
+
}
|
|
578
|
+
// pg_stat_statements is NOT installed - empty result
|
|
579
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
580
|
+
return { rowCount: 0, rows: [] };
|
|
581
|
+
}
|
|
582
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
583
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const r = await init.verifyInitSetup({
|
|
591
|
+
client: client as any,
|
|
592
|
+
database: "mydb",
|
|
593
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
594
|
+
includeOptionalPermissions: false,
|
|
595
|
+
});
|
|
596
|
+
// Should pass without errors - missing extension shouldn't cause failure
|
|
597
|
+
expect(r.ok).toBe(true);
|
|
598
|
+
expect(r.missingRequired.length).toBe(0);
|
|
599
|
+
// Should have queried for pg_stat_statements schema location
|
|
600
|
+
expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("verifyInitSetup skips extension schema check when in pg_catalog", async () => {
|
|
604
|
+
const calls: string[] = [];
|
|
605
|
+
const client = {
|
|
606
|
+
query: async (sql: string, params?: any) => {
|
|
607
|
+
calls.push(String(sql));
|
|
608
|
+
|
|
609
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
610
|
+
return { rowCount: 1, rows: [] };
|
|
611
|
+
}
|
|
612
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
613
|
+
return { rowCount: 1, rows: [] };
|
|
614
|
+
}
|
|
615
|
+
if (String(sql).includes("select rolconfig")) {
|
|
616
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
|
|
617
|
+
}
|
|
618
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
619
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
620
|
+
}
|
|
621
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
622
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
623
|
+
}
|
|
624
|
+
if (String(sql).includes("pg_has_role")) {
|
|
625
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
626
|
+
}
|
|
627
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
628
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
629
|
+
}
|
|
630
|
+
if (String(sql).includes("to_regclass")) {
|
|
631
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
632
|
+
}
|
|
633
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
634
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
635
|
+
}
|
|
636
|
+
// pg_stat_statements is in pg_catalog (standard location)
|
|
637
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
638
|
+
return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
|
|
639
|
+
}
|
|
640
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
641
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const r = await init.verifyInitSetup({
|
|
649
|
+
client: client as any,
|
|
650
|
+
database: "mydb",
|
|
651
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
652
|
+
includeOptionalPermissions: false,
|
|
653
|
+
});
|
|
654
|
+
// Should pass - pg_catalog doesn't need extra USAGE grant
|
|
655
|
+
expect(r.ok).toBe(true);
|
|
656
|
+
expect(r.missingRequired.length).toBe(0);
|
|
657
|
+
// Should NOT have queried for has_schema_privilege on pg_catalog specifically
|
|
658
|
+
// (the code skips the check for pg_catalog and public schemas)
|
|
659
|
+
const pgCatalogPrivCheck = calls.filter(
|
|
660
|
+
(c) => c.includes("has_schema_privilege") && c.includes("pg_catalog")
|
|
661
|
+
);
|
|
662
|
+
// Should only have the standard public schema check, not a pg_catalog check for extension
|
|
663
|
+
expect(pgCatalogPrivCheck.length).toBe(0);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("verifyInitSetup skips extension schema check when in public", async () => {
|
|
667
|
+
const calls: string[] = [];
|
|
668
|
+
const client = {
|
|
669
|
+
query: async (sql: string, params?: any) => {
|
|
670
|
+
calls.push(String(sql));
|
|
671
|
+
|
|
672
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
673
|
+
return { rowCount: 1, rows: [] };
|
|
674
|
+
}
|
|
675
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
676
|
+
return { rowCount: 1, rows: [] };
|
|
677
|
+
}
|
|
678
|
+
if (String(sql).includes("select rolconfig")) {
|
|
679
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
|
|
680
|
+
}
|
|
681
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
682
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
683
|
+
}
|
|
684
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
685
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
686
|
+
}
|
|
687
|
+
if (String(sql).includes("pg_has_role")) {
|
|
688
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
689
|
+
}
|
|
690
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
691
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
692
|
+
}
|
|
693
|
+
if (String(sql).includes("to_regclass")) {
|
|
694
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
695
|
+
}
|
|
696
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
697
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
698
|
+
}
|
|
699
|
+
// pg_stat_statements is in public schema
|
|
700
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
701
|
+
return { rowCount: 1, rows: [{ schema: "public" }] };
|
|
702
|
+
}
|
|
703
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
704
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const r = await init.verifyInitSetup({
|
|
712
|
+
client: client as any,
|
|
713
|
+
database: "mydb",
|
|
714
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
715
|
+
includeOptionalPermissions: false,
|
|
716
|
+
});
|
|
717
|
+
// Should pass - public doesn't need extra USAGE grant for extension
|
|
718
|
+
expect(r.ok).toBe(true);
|
|
719
|
+
expect(r.missingRequired.length).toBe(0);
|
|
720
|
+
});
|
|
721
|
+
|
|
385
722
|
test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
|
|
386
723
|
const plan = await init.buildInitPlan({
|
|
387
724
|
database: "mydb",
|