postgresai 0.14.0-dev.85 → 0.14.0-dev.86
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/dist/bin/postgres-ai.js +21 -10
- package/dist/sql/03.permissions.sql +41 -2
- package/dist/sql/sql/03.permissions.sql +41 -2
- package/lib/init.ts +31 -10
- package/package.json +1 -1
- package/sql/03.permissions.sql +41 -2
- package/test/init.test.ts +338 -1
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13064,7 +13064,7 @@ var {
|
|
|
13064
13064
|
// package.json
|
|
13065
13065
|
var package_default = {
|
|
13066
13066
|
name: "postgresai",
|
|
13067
|
-
version: "0.14.0-dev.
|
|
13067
|
+
version: "0.14.0-dev.86",
|
|
13068
13068
|
description: "postgres_ai CLI",
|
|
13069
13069
|
license: "Apache-2.0",
|
|
13070
13070
|
private: false,
|
|
@@ -15889,7 +15889,7 @@ var Result = import_lib.default.Result;
|
|
|
15889
15889
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
15890
15890
|
var defaults = import_lib.default.defaults;
|
|
15891
15891
|
// package.json
|
|
15892
|
-
var version = "0.14.0-dev.
|
|
15892
|
+
var version = "0.14.0-dev.86";
|
|
15893
15893
|
var package_default2 = {
|
|
15894
15894
|
name: "postgresai",
|
|
15895
15895
|
version,
|
|
@@ -24971,14 +24971,7 @@ end $$;`;
|
|
|
24971
24971
|
});
|
|
24972
24972
|
let permissionsSql = applyTemplate(loadSqlTemplate("03.permissions.sql"), vars);
|
|
24973
24973
|
if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
|
|
24974
|
-
permissionsSql = permissionsSql.
|
|
24975
|
-
`).filter((line) => {
|
|
24976
|
-
const trimmed = line.trim();
|
|
24977
|
-
if (trimmed.startsWith("--") || trimmed === "")
|
|
24978
|
-
return true;
|
|
24979
|
-
return !/^\s*alter\s+user\s+/i.test(line);
|
|
24980
|
-
}).join(`
|
|
24981
|
-
`);
|
|
24974
|
+
permissionsSql = permissionsSql.replace(/-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/, "");
|
|
24982
24975
|
}
|
|
24983
24976
|
steps.push({
|
|
24984
24977
|
name: "03.permissions",
|
|
@@ -25162,6 +25155,19 @@ async function verifyInitSetup(params) {
|
|
|
25162
25155
|
if (!schemaUsageRes.rows?.[0]?.ok) {
|
|
25163
25156
|
missingRequired.push("USAGE on schema public");
|
|
25164
25157
|
}
|
|
25158
|
+
const extSchemaRes = await params.client.query(`
|
|
25159
|
+
select n.nspname as schema
|
|
25160
|
+
from pg_extension e
|
|
25161
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
25162
|
+
where e.extname = 'pg_stat_statements'
|
|
25163
|
+
`);
|
|
25164
|
+
const extSchema = extSchemaRes.rows?.[0]?.schema;
|
|
25165
|
+
if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
|
|
25166
|
+
const extSchemaUsageRes = await params.client.query("select has_schema_privilege($1, $2, 'USAGE') as ok", [role, extSchema]);
|
|
25167
|
+
if (!extSchemaUsageRes.rows?.[0]?.ok) {
|
|
25168
|
+
missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
|
|
25169
|
+
}
|
|
25170
|
+
}
|
|
25165
25171
|
if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
|
|
25166
25172
|
const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
25167
25173
|
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
|
|
@@ -25173,6 +25179,11 @@ async function verifyInitSetup(params) {
|
|
|
25173
25179
|
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
25174
25180
|
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
25175
25181
|
}
|
|
25182
|
+
if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
|
|
25183
|
+
if (!sp.includes(extSchema.toLowerCase())) {
|
|
25184
|
+
missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
|
|
25185
|
+
}
|
|
25186
|
+
}
|
|
25176
25187
|
}
|
|
25177
25188
|
}
|
|
25178
25189
|
const explainFnRes = await params.client.query("select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok", [role]);
|
|
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
|
32
32
|
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
33
33
|
grant usage on schema public to {{ROLE_IDENT}};
|
|
34
34
|
|
|
35
|
-
--
|
|
36
|
-
|
|
35
|
+
-- Grant access to the schema where pg_stat_statements is installed.
|
|
36
|
+
-- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
|
|
37
|
+
-- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
|
|
38
|
+
do $$
|
|
39
|
+
declare
|
|
40
|
+
ext_schema text;
|
|
41
|
+
begin
|
|
42
|
+
select n.nspname into ext_schema
|
|
43
|
+
from pg_extension e
|
|
44
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
45
|
+
where e.extname = 'pg_stat_statements';
|
|
46
|
+
|
|
47
|
+
-- Only grant if extension exists and is in a non-standard schema
|
|
48
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
49
|
+
execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
|
|
50
|
+
end if;
|
|
51
|
+
end $$;
|
|
52
|
+
|
|
53
|
+
-- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
|
|
54
|
+
-- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
|
|
55
|
+
do $$
|
|
56
|
+
declare
|
|
57
|
+
ext_schema text;
|
|
58
|
+
sp text;
|
|
59
|
+
begin
|
|
60
|
+
-- Detect pg_stat_statements extension schema
|
|
61
|
+
select n.nspname into ext_schema
|
|
62
|
+
from pg_extension e
|
|
63
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
64
|
+
where e.extname = 'pg_stat_statements';
|
|
65
|
+
|
|
66
|
+
-- Build search_path: include extension schema if in non-standard location
|
|
67
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
68
|
+
sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
|
|
69
|
+
else
|
|
70
|
+
sp := 'postgres_ai, "$user", public, pg_catalog';
|
|
71
|
+
end if;
|
|
72
|
+
|
|
73
|
+
execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
|
|
74
|
+
end $$;
|
|
75
|
+
-- [SEARCH_PATH_BLOCK_END]
|
|
37
76
|
|
|
38
77
|
|
|
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
|
32
32
|
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
33
33
|
grant usage on schema public to {{ROLE_IDENT}};
|
|
34
34
|
|
|
35
|
-
--
|
|
36
|
-
|
|
35
|
+
-- Grant access to the schema where pg_stat_statements is installed.
|
|
36
|
+
-- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
|
|
37
|
+
-- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
|
|
38
|
+
do $$
|
|
39
|
+
declare
|
|
40
|
+
ext_schema text;
|
|
41
|
+
begin
|
|
42
|
+
select n.nspname into ext_schema
|
|
43
|
+
from pg_extension e
|
|
44
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
45
|
+
where e.extname = 'pg_stat_statements';
|
|
46
|
+
|
|
47
|
+
-- Only grant if extension exists and is in a non-standard schema
|
|
48
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
49
|
+
execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
|
|
50
|
+
end if;
|
|
51
|
+
end $$;
|
|
52
|
+
|
|
53
|
+
-- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
|
|
54
|
+
-- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
|
|
55
|
+
do $$
|
|
56
|
+
declare
|
|
57
|
+
ext_schema text;
|
|
58
|
+
sp text;
|
|
59
|
+
begin
|
|
60
|
+
-- Detect pg_stat_statements extension schema
|
|
61
|
+
select n.nspname into ext_schema
|
|
62
|
+
from pg_extension e
|
|
63
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
64
|
+
where e.extname = 'pg_stat_statements';
|
|
65
|
+
|
|
66
|
+
-- Build search_path: include extension schema if in non-standard location
|
|
67
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
68
|
+
sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
|
|
69
|
+
else
|
|
70
|
+
sp := 'postgres_ai, "$user", public, pg_catalog';
|
|
71
|
+
end if;
|
|
72
|
+
|
|
73
|
+
execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
|
|
74
|
+
end $$;
|
|
75
|
+
-- [SEARCH_PATH_BLOCK_END]
|
|
37
76
|
|
|
38
77
|
|
package/lib/init.ts
CHANGED
|
@@ -538,16 +538,12 @@ end $$;`;
|
|
|
538
538
|
// Some providers restrict ALTER USER - remove those statements.
|
|
539
539
|
// TODO: Make this more flexible by allowing users to specify which statements to skip via config.
|
|
540
540
|
if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
// Filter out ALTER USER statements (case-insensitive, flexible whitespace)
|
|
548
|
-
return !/^\s*alter\s+user\s+/i.test(line);
|
|
549
|
-
})
|
|
550
|
-
.join("\n");
|
|
541
|
+
// Remove the entire search_path DO block (marked with SEARCH_PATH_BLOCK_START/END)
|
|
542
|
+
// since it contains ALTER USER and can't be line-filtered without breaking the DO block.
|
|
543
|
+
permissionsSql = permissionsSql.replace(
|
|
544
|
+
/-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/,
|
|
545
|
+
""
|
|
546
|
+
);
|
|
551
547
|
}
|
|
552
548
|
|
|
553
549
|
steps.push({
|
|
@@ -838,6 +834,24 @@ export async function verifyInitSetup(params: {
|
|
|
838
834
|
missingRequired.push("USAGE on schema public");
|
|
839
835
|
}
|
|
840
836
|
|
|
837
|
+
// Check access to pg_stat_statements extension schema (may be 'extensions' on Supabase)
|
|
838
|
+
const extSchemaRes = await params.client.query(`
|
|
839
|
+
select n.nspname as schema
|
|
840
|
+
from pg_extension e
|
|
841
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
842
|
+
where e.extname = 'pg_stat_statements'
|
|
843
|
+
`);
|
|
844
|
+
const extSchema = extSchemaRes.rows?.[0]?.schema;
|
|
845
|
+
if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
|
|
846
|
+
const extSchemaUsageRes = await params.client.query(
|
|
847
|
+
"select has_schema_privilege($1, $2, 'USAGE') as ok",
|
|
848
|
+
[role, extSchema]
|
|
849
|
+
);
|
|
850
|
+
if (!extSchemaUsageRes.rows?.[0]?.ok) {
|
|
851
|
+
missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
841
855
|
// Some providers don't allow setting search_path via ALTER USER - skip this check.
|
|
842
856
|
// TODO: Make this more flexible by allowing users to specify which checks to skip via config.
|
|
843
857
|
if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
|
|
@@ -848,10 +862,17 @@ export async function verifyInitSetup(params: {
|
|
|
848
862
|
missingRequired.push("role search_path is set");
|
|
849
863
|
} else {
|
|
850
864
|
// We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
|
|
865
|
+
// Also verify search_path includes the pg_stat_statements schema if in a non-standard location.
|
|
851
866
|
const sp = spLine.toLowerCase();
|
|
852
867
|
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
853
868
|
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
854
869
|
}
|
|
870
|
+
// If pg_stat_statements is in a non-standard schema (e.g., 'extensions' on Supabase), verify it's in search_path
|
|
871
|
+
if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
|
|
872
|
+
if (!sp.includes(extSchema.toLowerCase())) {
|
|
873
|
+
missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
855
876
|
}
|
|
856
877
|
}
|
|
857
878
|
|
package/package.json
CHANGED
package/sql/03.permissions.sql
CHANGED
|
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
|
32
32
|
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
33
33
|
grant usage on schema public to {{ROLE_IDENT}};
|
|
34
34
|
|
|
35
|
-
--
|
|
36
|
-
|
|
35
|
+
-- Grant access to the schema where pg_stat_statements is installed.
|
|
36
|
+
-- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
|
|
37
|
+
-- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
|
|
38
|
+
do $$
|
|
39
|
+
declare
|
|
40
|
+
ext_schema text;
|
|
41
|
+
begin
|
|
42
|
+
select n.nspname into ext_schema
|
|
43
|
+
from pg_extension e
|
|
44
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
45
|
+
where e.extname = 'pg_stat_statements';
|
|
46
|
+
|
|
47
|
+
-- Only grant if extension exists and is in a non-standard schema
|
|
48
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
49
|
+
execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
|
|
50
|
+
end if;
|
|
51
|
+
end $$;
|
|
52
|
+
|
|
53
|
+
-- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
|
|
54
|
+
-- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
|
|
55
|
+
do $$
|
|
56
|
+
declare
|
|
57
|
+
ext_schema text;
|
|
58
|
+
sp text;
|
|
59
|
+
begin
|
|
60
|
+
-- Detect pg_stat_statements extension schema
|
|
61
|
+
select n.nspname into ext_schema
|
|
62
|
+
from pg_extension e
|
|
63
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
64
|
+
where e.extname = 'pg_stat_statements';
|
|
65
|
+
|
|
66
|
+
-- Build search_path: include extension schema if in non-standard location
|
|
67
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
68
|
+
sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
|
|
69
|
+
else
|
|
70
|
+
sp := 'postgres_ai, "$user", public, pg_catalog';
|
|
71
|
+
end if;
|
|
72
|
+
|
|
73
|
+
execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
|
|
74
|
+
end $$;
|
|
75
|
+
-- [SEARCH_PATH_BLOCK_END]
|
|
37
76
|
|
|
38
77
|
|
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",
|