Skip to content

Commit 80da1e8

Browse files
committed
Merge branch 'fix/53-check-db-user-permissions' into 'main'
fix: check DB user permissions on checkup startup Closes #53 See merge request postgres-ai/postgresai!232
2 parents 3610ced + 3d23615 commit 80da1e8

File tree

6 files changed

+868
-3
lines changed

6 files changed

+868
-3
lines changed

cli/bin/postgres-ai.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, create
1515
import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
1616
import { resolveBaseUrls } from "../lib/util";
1717
import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
18-
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, connectWithSslFallback, DEFAULT_MONITORING_USER, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
18+
import { applyInitPlan, applyUninitPlan, buildInitPlan, buildUninitPlan, checkCurrentUserPermissions, connectWithSslFallback, DEFAULT_MONITORING_USER, formatPermissionCheckMessages, KNOWN_PROVIDERS, redactPasswordsInSql, resolveAdminConnection, resolveMonitoringPassword, validateProvider, verifyInitSetup } from "../lib/init";
1919
import { SupabaseClient, resolveSupabaseConfig, extractProjectRefFromUrl, applyInitPlanViaSupabase, verifyInitSetupViaSupabase, fetchPoolerDatabaseUrl, type PgCompatibleError } from "../lib/supabase";
2020
import * as pkce from "../lib/pkce";
2121
import * as authServer from "../lib/auth-server";
@@ -1817,6 +1817,24 @@ program
18171817
const connResult = await connectWithSslFallback(Client, adminConn);
18181818
client = connResult.client as Client;
18191819

1820+
// Preflight: verify the connected user has sufficient permissions
1821+
spinner.update("Checking database permissions");
1822+
const permCheck = await checkCurrentUserPermissions(client);
1823+
const permMessages = formatPermissionCheckMessages(permCheck);
1824+
1825+
for (const w of permMessages.warnings) {
1826+
console.error(w);
1827+
}
1828+
1829+
if (permMessages.failed) {
1830+
spinner.stop();
1831+
for (const e of permMessages.errors) {
1832+
console.error(e);
1833+
}
1834+
process.exitCode = 1;
1835+
return;
1836+
}
1837+
18201838
// Generate reports
18211839
let reports: Record<string, any>;
18221840
if (checkId === "ALL") {

cli/lib/init.ts

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,36 @@ export type VerifyInitResult = {
671671
missingOptional: string[];
672672
};
673673

674+
/** A single permission check result from the preflight query. */
675+
export type PermissionCheckRow = {
676+
permission_name: string;
677+
status: "required" | "optional";
678+
/**
679+
* Whether the permission is granted.
680+
* - `true` — permission is granted
681+
* - `false` — permission is explicitly denied
682+
* - `null` — check was skipped (e.g., object does not exist, so the privilege
683+
* check is inapplicable — such as SELECT on a view that hasn't been created)
684+
*/
685+
granted: boolean | null;
686+
fix_command: string | null;
687+
};
688+
689+
/**
690+
* Result of the preflight permission check for the current DB user.
691+
*
692+
* - `ok` is `true` when `missingRequired` is empty.
693+
* - `rows` contains every check (for inspection / logging).
694+
* - `missingRequired` / `missingOptional` are filtered subsets of `rows`
695+
* where the permission is not granted (`granted !== true`).
696+
*/
697+
export type PreflightPermissionResult = {
698+
ok: boolean;
699+
rows: PermissionCheckRow[];
700+
missingRequired: PermissionCheckRow[];
701+
missingOptional: PermissionCheckRow[];
702+
};
703+
674704
export type UninitPlan = {
675705
monitoringUser: string;
676706
database: string;
@@ -825,7 +855,12 @@ export async function verifyInitSetup(params: {
825855
missingRequired.push("USAGE on schema postgres_ai");
826856
}
827857

828-
const viewExistsRes = await params.client.query("select to_regclass('postgres_ai.pg_statistic') is not null as ok");
858+
const viewExistsRes = await params.client.query(`
859+
select case
860+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
861+
else to_regclass('postgres_ai.pg_statistic') is not null
862+
end as ok
863+
`);
829864
if (!viewExistsRes.rows?.[0]?.ok) {
830865
missingRequired.push("view postgres_ai.pg_statistic exists");
831866
} else {
@@ -948,4 +983,149 @@ export async function verifyInitSetup(params: {
948983
}
949984
}
950985

986+
/**
987+
* Check that the currently connected DB user has sufficient permissions for
988+
* monitoring operations. Returns structured results with fix commands.
989+
*
990+
* Required permissions cause startup to fail; optional ones produce warnings.
991+
*
992+
* @param client An already-connected PostgreSQL client.
993+
* @returns A {@link PreflightPermissionResult} with per-check rows and
994+
* filtered `missingRequired` / `missingOptional` arrays.
995+
* @throws Propagates database errors (network, permission denied on catalog
996+
* tables, timeout) to the caller.
997+
*/
998+
export async function checkCurrentUserPermissions(
999+
client: PgClient
1000+
): Promise<PreflightPermissionResult> {
1001+
const sql = `
1002+
with permission_checks as (
1003+
select
1004+
format('connect on database %I', current_database()) as permission_name,
1005+
'required' as status,
1006+
has_database_privilege(current_user, current_database(), 'connect') as granted
1007+
1008+
union all
1009+
1010+
select
1011+
'pg_monitor role membership' as permission_name,
1012+
'required' as status,
1013+
-- CASE guarantees evaluation order: pg_has_role() is only called if the
1014+
-- pg_monitor role exists, avoiding ERROR on PostgreSQL < 10 or when dropped.
1015+
case
1016+
when not exists (select from pg_roles where rolname = 'pg_monitor')
1017+
then false
1018+
else pg_has_role(current_user, 'pg_monitor', 'member')
1019+
end as granted
1020+
1021+
union all
1022+
1023+
select
1024+
'select on pg_catalog.pg_index' as permission_name,
1025+
'required' as status,
1026+
has_table_privilege(current_user, 'pg_catalog.pg_index', 'select') as granted
1027+
1028+
union all
1029+
1030+
select
1031+
'postgres_ai.pg_statistic view exists' as permission_name,
1032+
'optional' as status,
1033+
case
1034+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
1035+
else to_regclass('postgres_ai.pg_statistic') is not null
1036+
end as granted
1037+
1038+
union all
1039+
1040+
select
1041+
'select on postgres_ai.pg_statistic' as permission_name,
1042+
'optional' as status,
1043+
case
1044+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
1045+
when to_regclass('postgres_ai.pg_statistic') is null then null
1046+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
1047+
end as granted
1048+
)
1049+
select
1050+
permission_name,
1051+
status,
1052+
granted,
1053+
case
1054+
when status = 'required' and not coalesce(granted, false) then
1055+
case
1056+
when permission_name like 'connect%' then
1057+
format('grant connect on database %I to %I;', current_database(), current_user)
1058+
when permission_name = 'pg_monitor role membership' then
1059+
format('grant pg_monitor to %I;', current_user)
1060+
when permission_name like 'select on pg_catalog.pg_index' then
1061+
format('grant select on pg_catalog.pg_index to %I;', current_user)
1062+
end
1063+
when permission_name = 'postgres_ai.pg_statistic view exists' and granted = false then
1064+
'-- create postgres_ai.pg_statistic view (see setup script)'
1065+
when permission_name = 'select on postgres_ai.pg_statistic' and granted = false then
1066+
format('grant select on postgres_ai.pg_statistic to %I;', current_user)
1067+
else null
1068+
end as fix_command
1069+
from permission_checks
1070+
order by
1071+
case status when 'required' then 1 else 2 end,
1072+
permission_name;
1073+
`;
1074+
1075+
const res = await client.query(sql);
1076+
const rows: PermissionCheckRow[] = res.rows;
1077+
1078+
// Required: treat null (skipped) as not-granted — fail safe.
1079+
// Optional: only explicit false counts as missing; null means the check was
1080+
// skipped (e.g., view doesn't exist) and is not actionable.
1081+
const missingRequired = rows.filter((r) => r.status === "required" && r.granted !== true);
1082+
const missingOptional = rows.filter((r) => r.status === "optional" && r.granted === false);
1083+
1084+
return {
1085+
ok: missingRequired.length === 0,
1086+
rows,
1087+
missingRequired,
1088+
missingOptional,
1089+
};
1090+
}
1091+
1092+
/**
1093+
* Format permission check results into user-facing error/warning lines.
1094+
*
1095+
* @returns An object with `warnings` (for optional misses), `errors` (for
1096+
* required misses including fix SQL), and `failed` (whether required
1097+
* permissions are missing).
1098+
*/
1099+
export function formatPermissionCheckMessages(result: PreflightPermissionResult): {
1100+
failed: boolean;
1101+
warnings: string[];
1102+
errors: string[];
1103+
} {
1104+
const warnings: string[] = [];
1105+
const errors: string[] = [];
1106+
1107+
for (const row of result.missingOptional) {
1108+
const fix = row.fix_command ? ` Fix: ${row.fix_command}` : "";
1109+
warnings.push(`Warning: optional permission missing — ${row.permission_name}.${fix}`);
1110+
}
9511111

1112+
if (!result.ok) {
1113+
errors.push("Error: the database user is missing required permissions.\n");
1114+
errors.push("Missing permissions:");
1115+
for (const row of result.missingRequired) {
1116+
errors.push(` - ${row.permission_name}`);
1117+
}
1118+
const fixes = result.missingRequired
1119+
.map((r) => r.fix_command)
1120+
.filter(Boolean);
1121+
if (fixes.length > 0) {
1122+
errors.push("\nTo fix, run the following as a superuser:\n");
1123+
for (const fix of fixes) {
1124+
errors.push(` ${fix}`);
1125+
}
1126+
}
1127+
errors.push("\nAlternatively, run 'postgresai prepare-db' to set up permissions automatically.");
1128+
}
1129+
1130+
return { failed: !result.ok, warnings, errors };
1131+
}

cli/lib/supabase.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,10 @@ export async function verifyInitSetupViaSupabase(params: {
673673

674674
// Check pg_statistic view
675675
const viewExistsRes = await params.client.query(
676-
"SELECT to_regclass('postgres_ai.pg_statistic') IS NOT NULL as ok",
676+
`SELECT CASE
677+
WHEN NOT has_schema_privilege(current_user, 'postgres_ai', 'USAGE') THEN NULL
678+
ELSE to_regclass('postgres_ai.pg_statistic') IS NOT NULL
679+
END as ok`,
677680
true
678681
);
679682
if (!viewExistsRes.rows?.[0]?.ok) {
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Permission Check Test Summary
2+
3+
## Changes Made
4+
5+
Changed all references from `public.pg_statistic` to `postgres_ai.pg_statistic` in:
6+
- `cli/lib/init.ts` - Permission check SQL query
7+
- `cli/test/init.test.ts` - All test expectations (28 occurrences)
8+
9+
## Key Fix: Safe Schema Checking
10+
11+
**Before (883fa95):**
12+
```sql
13+
exists (
14+
select from pg_views
15+
where schemaname = 'public' and viewname = 'pg_statistic'
16+
) as granted
17+
```
18+
19+
**After (6db79f6) - INCORRECT, caused crashes:**
20+
```sql
21+
to_regclass('postgres_ai.pg_statistic') is not null as granted
22+
```
23+
24+
**Current (this fix):**
25+
```sql
26+
case
27+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
28+
else to_regclass('postgres_ai.pg_statistic') is not null
29+
end as granted
30+
```
31+
32+
### Why this fix matters
33+
34+
**Issue with bare `to_regclass()`:**
35+
- Returns NULL when the schema doesn't exist ✓
36+
- Returns NULL when the view doesn't exist ✓
37+
- **Throws error** when the schema exists but user lacks USAGE privilege ✗
38+
39+
**Fix:**
40+
- Check `has_schema_privilege()` first to avoid the permission error
41+
- Returns NULL safely in all cases where we can't check the view
42+
- Prevents crashes when postgres_ai schema exists but user lacks USAGE
43+
44+
## Test Results
45+
46+
### Unit Tests ✅
47+
```
48+
✓ 95 tests passed across 3 files
49+
- 84 tests in init.test.ts (including 9 checkCurrentUserPermissions tests)
50+
- 2 tests in config-consistency.test.ts
51+
- 9 tests in permission-check-sql.test.ts
52+
```
53+
54+
### Expected Behavior by Scenario
55+
56+
| Scenario | User Permissions | postgres_ai Schema | Expected Result |
57+
|----------|-----------------|-------------------|-----------------|
58+
| 1. Superuser | superuser + postgres_ai.pg_statistic | ✓ Exists | ✅ PASS (clean) |
59+
| 2. pg_monitor, no schema access | pg_monitor only | ✗ No USAGE | ✅ PASS (warning) |
60+
| 3. No pg_monitor | minimal permissions | ✗ Doesn't exist | ✅ PASS (error + fix SQL) |
61+
| 8. After prepare-db | pg_monitor + postgres_ai grants | ✓ Exists + SELECT | ✅ PASS (clean) |
62+
63+
### SQL Behavior Verification
64+
65+
**Scenario 2 & 3: Schema doesn't exist or no USAGE**
66+
```sql
67+
-- Check privilege first, then to_regclass (no crash)
68+
case
69+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
70+
else to_regclass('postgres_ai.pg_statistic') is not null
71+
end → NULL
72+
73+
-- SELECT check is skipped (returns NULL, not treated as missing optional)
74+
case
75+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
76+
when to_regclass('postgres_ai.pg_statistic') is null then null
77+
else has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select')
78+
end → NULL
79+
```
80+
81+
**Scenario 1 & 8: Schema exists with proper grants**
82+
```sql
83+
-- User has USAGE, to_regclass returns OID (view is visible)
84+
case
85+
when not has_schema_privilege(current_user, 'postgres_ai', 'USAGE') then null
86+
else to_regclass('postgres_ai.pg_statistic') is not null
87+
end → TRUE
88+
89+
-- SELECT check is performed
90+
has_table_privilege(current_user, 'postgres_ai.pg_statistic', 'select') → TRUE/FALSE
91+
```
92+
93+
## Integration Test Limitations
94+
95+
Integration tests cannot run due to locale configuration issues with `initdb`:
96+
```
97+
error: initdb: error: invalid locale settings; check LANG and LC_* environment variables
98+
```
99+
100+
However, unit tests provide comprehensive coverage of the permission check logic, including:
101+
- All permission scenarios (granted, denied, skipped)
102+
- Multiple missing permissions
103+
- Error propagation
104+
- Fix command generation
105+
- Message formatting
106+
107+
## Schema Consistency
108+
109+
The change ensures consistency across the codebase:
110+
-`cli/lib/init.ts` - now checks postgres_ai.pg_statistic
111+
-`cli/lib/supabase.ts` - already checks postgres_ai.pg_statistic
112+
-`cli/sql/03.permissions.sql` - creates postgres_ai.pg_statistic
113+
-`config/target-db/init.sql` - creates postgres_ai.pg_statistic
114+
-`config/pgwatch-prometheus/metrics.yml` - references postgres_ai.pg_statistic
115+
116+
## Commits
117+
118+
1. **955cff2** - `fix: change public.pg_statistic to postgres_ai.pg_statistic`
119+
- Updated permission check queries
120+
- Updated all test expectations
121+
122+
2. **6db79f6** - `fix: use to_regclass() for safe postgres_ai.pg_statistic check`
123+
- Replaced pg_views query with to_regclass()
124+
- ⚠️ This introduced a bug: crashes when schema exists but user lacks USAGE
125+
126+
3. **[current]** - `fix: wrap to_regclass() with has_schema_privilege() check`
127+
- Fixed crash when postgres_ai schema exists but user lacks USAGE privilege
128+
- Added privilege check before calling to_regclass() in all locations
129+
- Updated in: init.ts (3 places) and supabase.ts (1 place)
130+
131+
## Verification Command
132+
133+
```bash
134+
# Run all permission-related tests
135+
bun test test/init.test.ts test/config-consistency.test.ts test/permission-check-sql.test.ts
136+
137+
# Verify no public.pg_statistic references remain (except in comments)
138+
git grep -n 'public\.pg_statistic' cli/
139+
```

0 commit comments

Comments
 (0)