@@ -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+
674704export 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+ }
0 commit comments