@kazurolabs/psql-explorer 0.0.0-stage → 0.3.0

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.
Files changed (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +505 -2
  3. package/RELEASES.md +87 -0
  4. package/dist/app.js +39 -0
  5. package/dist/cli.js +307 -0
  6. package/dist/commands/configgen.js +128 -0
  7. package/dist/commands/explore.js +388 -0
  8. package/dist/commands/terminal-prompt.js +68 -0
  9. package/dist/config/load.js +324 -0
  10. package/dist/config/resolve-environment.js +112 -0
  11. package/dist/config/resolvers/env.js +12 -0
  12. package/dist/config/resolvers/file.js +24 -0
  13. package/dist/config/resolvers/groups.js +1 -0
  14. package/dist/config/resolvers/index.js +49 -0
  15. package/dist/config/resolvers/literal.js +7 -0
  16. package/dist/config/resolvers/prompt.js +159 -0
  17. package/dist/config/resolvers/stored.js +13 -0
  18. package/dist/config/resolvers/types.js +18 -0
  19. package/dist/config/resolvers/vault.js +283 -0
  20. package/dist/config/schema.js +24 -0
  21. package/dist/config/source.js +105 -0
  22. package/dist/db/connect.js +55 -0
  23. package/dist/db/identifiers.js +6 -0
  24. package/dist/db/paths.js +14 -0
  25. package/dist/db/store.js +323 -0
  26. package/dist/db/tester.js +190 -0
  27. package/dist/db/validate.js +99 -0
  28. package/dist/environments.js +140 -0
  29. package/dist/explorer/alter.js +493 -0
  30. package/dist/explorer/catalog.js +414 -0
  31. package/dist/explorer/connect-entry.js +42 -0
  32. package/dist/explorer/cypher-parse.js +478 -0
  33. package/dist/explorer/cypher.js +963 -0
  34. package/dist/explorer/ddl.js +502 -0
  35. package/dist/explorer/dependents.js +222 -0
  36. package/dist/explorer/design-load.js +254 -0
  37. package/dist/explorer/design.js +45 -0
  38. package/dist/explorer/edit.js +108 -0
  39. package/dist/explorer/explain.js +37 -0
  40. package/dist/explorer/filter.js +121 -0
  41. package/dist/explorer/format.js +134 -0
  42. package/dist/explorer/graph.js +679 -0
  43. package/dist/explorer/gucs.js +31 -0
  44. package/dist/explorer/profile.js +102 -0
  45. package/dist/explorer/references.js +95 -0
  46. package/dist/explorer/rows.js +64 -0
  47. package/dist/explorer/schema-diff.js +360 -0
  48. package/dist/explorer/search.js +309 -0
  49. package/dist/explorer/security.js +152 -0
  50. package/dist/explorer/seed.js +1376 -0
  51. package/dist/explorer/session.js +122 -0
  52. package/dist/explorer/stats.js +135 -0
  53. package/dist/explorer/timeout.js +13 -0
  54. package/dist/prompts.js +52 -0
  55. package/dist/screens/environments.js +229 -0
  56. package/dist/screens/explorer.js +145 -0
  57. package/dist/screens/header.js +8 -0
  58. package/dist/screens/index.js +13 -0
  59. package/dist/screens/new-connection.js +178 -0
  60. package/dist/screens/panes/common.js +35 -0
  61. package/dist/screens/panes/history.js +36 -0
  62. package/dist/screens/panes/info.js +61 -0
  63. package/dist/screens/panes/query.js +113 -0
  64. package/dist/screens/panes/table.js +178 -0
  65. package/dist/screens/panes/tables.js +142 -0
  66. package/dist/screens/prompt-modal.js +18 -0
  67. package/dist/screens/record-view.js +42 -0
  68. package/dist/screens/result-grid.js +126 -0
  69. package/dist/screens/scroll.js +12 -0
  70. package/dist/screens/splash.js +78 -0
  71. package/dist/screens/terminal.js +25 -0
  72. package/dist/screens/types.js +1 -0
  73. package/dist/services.js +1 -0
  74. package/dist/startup.js +84 -0
  75. package/dist/web/server.js +988 -0
  76. package/package.json +69 -5
  77. package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
  78. package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
  79. package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
  80. package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
  81. package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
  82. package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
  83. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  84. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  85. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  86. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  87. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  88. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  89. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  90. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  91. package/web/dist/assets/button-ClA70PLR.js +1 -0
  92. package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
  93. package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
  94. package/web/dist/assets/copy-CCXzeW-s.js +1 -0
  95. package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
  96. package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
  97. package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
  98. package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
  99. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  100. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  101. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  102. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  103. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  104. package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
  105. package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
  106. package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
  107. package/web/dist/assets/index-CDcnUacl.js +10 -0
  108. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  109. package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
  110. package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
  111. package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
  112. package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
  113. package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
  114. package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
  115. package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
  116. package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
  117. package/web/dist/assets/route-DO6IZ7of.js +15 -0
  118. package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
  119. package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
  120. package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
  121. package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
  122. package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
  123. package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
  124. package/web/dist/assets/table-DfMz2f8M.js +1 -0
  125. package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
  126. package/web/dist/assets/zone-D9dXk8tn.js +5 -0
  127. package/web/dist/index.html +26 -0
@@ -0,0 +1,309 @@
1
+ // Finding things in a database: a flat index of its object names for a search box, and a search for
2
+ // a value through the text, uuid and integer columns of its tables.
3
+ import { quoteIdentifier } from '../db/identifiers.js';
4
+ import { qualified } from './catalog.js';
5
+ export const MAX_INDEXED_COLUMNS = 50_000;
6
+ const relationKinds = {
7
+ r: 'table',
8
+ p: 'partitioned',
9
+ v: 'view',
10
+ m: 'materialized',
11
+ f: 'foreign',
12
+ };
13
+ // The schemas a person works with, as in listSchemas.
14
+ const userSchema = `n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'`;
15
+ /**
16
+ * Every table, view, column, function, sequence, type and enum value of the schemas a person works
17
+ * with, with comments, for matching against what is typed in a search box.
18
+ */
19
+ export async function buildSearchIndex(session) {
20
+ const entries = [];
21
+ const relations = await session.rows(`SELECT n.nspname AS schema, c.relname AS name, c.relkind AS kind,
22
+ pg_catalog.obj_description(c.oid, 'pg_class') AS comment
23
+ FROM pg_catalog.pg_class c
24
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
25
+ WHERE ${userSchema} AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
26
+ ORDER BY n.nspname, c.relname`);
27
+ for (const row of relations) {
28
+ entries.push({
29
+ kind: relationKinds[row.kind ?? ''] ?? 'table',
30
+ schema: row.schema ?? '',
31
+ name: row.name ?? '',
32
+ parent: null,
33
+ detail: null,
34
+ comment: row.comment,
35
+ });
36
+ }
37
+ const columns = await session.rows(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS name,
38
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
39
+ pg_catalog.col_description(a.attrelid, a.attnum) AS comment
40
+ FROM pg_catalog.pg_attribute a
41
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
42
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
43
+ WHERE ${userSchema} AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
44
+ AND a.attnum > 0 AND NOT a.attisdropped
45
+ ORDER BY n.nspname, c.relname, a.attnum
46
+ LIMIT $1`, [MAX_INDEXED_COLUMNS + 1]);
47
+ const truncated = columns.length > MAX_INDEXED_COLUMNS;
48
+ for (const row of columns.slice(0, MAX_INDEXED_COLUMNS)) {
49
+ entries.push({
50
+ kind: 'column',
51
+ schema: row.schema ?? '',
52
+ name: row.name ?? '',
53
+ parent: row.table,
54
+ detail: row.type,
55
+ comment: row.comment,
56
+ });
57
+ }
58
+ const labels = await session.rows(`SELECT n.nspname AS schema, t.typname AS type, e.enumlabel AS label
59
+ FROM pg_catalog.pg_enum e
60
+ JOIN pg_catalog.pg_type t ON t.oid = e.enumtypid
61
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
62
+ WHERE ${userSchema}
63
+ ORDER BY n.nspname, t.typname, e.enumsortorder`);
64
+ for (const row of labels) {
65
+ entries.push({
66
+ kind: 'enum value',
67
+ schema: row.schema ?? '',
68
+ name: row.label ?? '',
69
+ parent: row.type,
70
+ detail: null,
71
+ comment: null,
72
+ });
73
+ }
74
+ const functions = await session.rows(`SELECT n.nspname AS schema, p.proname AS name,
75
+ pg_catalog.pg_get_function_identity_arguments(p.oid) AS args,
76
+ pg_catalog.obj_description(p.oid, 'pg_proc') AS comment
77
+ FROM pg_catalog.pg_proc p
78
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
79
+ WHERE ${userSchema}
80
+ ORDER BY n.nspname, p.proname, args`);
81
+ for (const row of functions) {
82
+ entries.push({
83
+ kind: 'function',
84
+ schema: row.schema ?? '',
85
+ name: row.name ?? '',
86
+ parent: null,
87
+ detail: row.args ?? '',
88
+ comment: row.comment,
89
+ });
90
+ }
91
+ const sequences = await session.rows(`SELECT schemaname AS schema, sequencename AS name FROM pg_catalog.pg_sequences
92
+ WHERE schemaname !~ '^pg_' AND schemaname <> 'information_schema'
93
+ ORDER BY schemaname, sequencename`);
94
+ for (const row of sequences) {
95
+ entries.push({
96
+ kind: 'sequence',
97
+ schema: row.schema ?? '',
98
+ name: row.name ?? '',
99
+ parent: null,
100
+ detail: null,
101
+ comment: null,
102
+ });
103
+ }
104
+ const types = await session.rows(`SELECT n.nspname AS schema, t.typname AS name, t.typtype AS kind,
105
+ pg_catalog.obj_description(t.oid, 'pg_type') AS comment
106
+ FROM pg_catalog.pg_type t
107
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
108
+ WHERE ${userSchema} AND t.typtype IN ('e', 'c', 'd', 'r', 'm')
109
+ AND (t.typtype <> 'c' OR EXISTS (
110
+ SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))
111
+ ORDER BY n.nspname, t.typname`);
112
+ const typeKinds = {
113
+ e: 'enum',
114
+ c: 'composite',
115
+ d: 'domain',
116
+ r: 'range',
117
+ m: 'multirange',
118
+ };
119
+ for (const row of types) {
120
+ entries.push({
121
+ kind: 'type',
122
+ schema: row.schema ?? '',
123
+ name: row.name ?? '',
124
+ parent: null,
125
+ detail: typeKinds[row.kind ?? ''] ?? null,
126
+ comment: row.comment,
127
+ });
128
+ }
129
+ return { entries, truncated };
130
+ }
131
+ export const DEFAULT_TABLE_TIMEOUT_MS = 5_000;
132
+ export const DEFAULT_DEADLINE_MS = 30_000;
133
+ export const DEFAULT_MAX_TABLES = 200;
134
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
135
+ const integerPattern = /^[+-]?\d{1,18}$/;
136
+ const valueKinds = {
137
+ text: 'text',
138
+ varchar: 'text',
139
+ bpchar: 'text',
140
+ name: 'text',
141
+ citext: 'text',
142
+ uuid: 'uuid',
143
+ int2: 'integer',
144
+ int4: 'integer',
145
+ int8: 'integer',
146
+ };
147
+ /** The column kinds a text can be compared with, e.g. a uuid is only compared with uuid columns. */
148
+ export function comparableKinds(text) {
149
+ const kinds = new Set(['text']);
150
+ if (uuidPattern.test(text))
151
+ kinds.add('uuid');
152
+ if (integerPattern.test(text))
153
+ kinds.add('integer');
154
+ return kinds;
155
+ }
156
+ /** Escapes the characters that mean something to LIKE. */
157
+ export const escapeLike = (text) => text.replace(/[\\%_]/g, (char) => `\\${char}`);
158
+ const likePattern = (text) => `%${escapeLike(text)}%`;
159
+ const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
160
+ /**
161
+ * The condition that finds `text` in a column of the given kind. A text is compared by `$1`, which
162
+ * holds a LIKE pattern for it; a uuid and an integer are written out, which is safe because
163
+ * `comparableKinds` only lets through text of exactly their shape.
164
+ */
165
+ const conditionFor = (column, kind, text) => kind === 'text'
166
+ ? `${column} ILIKE $1`
167
+ : kind === 'uuid'
168
+ ? `${column} = ${quoteLiteral(text)}::uuid`
169
+ : `${column} = ${text.replace(/^\+/, '')}`;
170
+ /** A statement that lists the rows of a table holding the text in one column. */
171
+ export function valueSql(table, column, kind, text) {
172
+ const name = quoteIdentifier(column);
173
+ const where = kind === 'text'
174
+ ? `${name} ILIKE ${quoteLiteral(likePattern(text))}`
175
+ : conditionFor(name, kind, text);
176
+ return `SELECT * FROM ${qualified(table)} WHERE ${where} LIMIT 100`;
177
+ }
178
+ const timedOut = (error) => error?.code === '57014';
179
+ /**
180
+ * Looks for `text` in every text, uuid and integer column of the tables of a schema (or of all),
181
+ * reading each table once. Given several sessions, the tables are shared out over them, one table at a time on
182
+ * each, so more sessions read more tables side by side. The work is bounded: a table that takes
183
+ * longer than `tableTimeoutMs` is skipped, and so is everything left once `deadlineMs` has passed
184
+ * or `maxTables` are done, each with the reason in the result. Views are not read. Only SELECT
185
+ * statements are run.
186
+ */
187
+ export async function searchValues(sessionOrSessions, { text, schema }, { tableTimeoutMs = DEFAULT_TABLE_TIMEOUT_MS, deadlineMs = DEFAULT_DEADLINE_MS, maxTables = DEFAULT_MAX_TABLES, } = {}) {
188
+ const sessions = Array.isArray(sessionOrSessions)
189
+ ? sessionOrSessions
190
+ : [sessionOrSessions];
191
+ const [first] = sessions;
192
+ if (!first)
193
+ throw new Error('searchValues needs a session');
194
+ const kinds = comparableKinds(text);
195
+ const found = await first.rows(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS column,
196
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, t.typname AS udt
197
+ FROM pg_catalog.pg_attribute a
198
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
199
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
200
+ JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
201
+ WHERE ${userSchema} AND c.relkind IN ('r', 'p')
202
+ AND ($1::text IS NULL OR n.nspname = $1)
203
+ AND a.attnum > 0 AND NOT a.attisdropped
204
+ AND t.typname = ANY($2::text[])
205
+ AND pg_catalog.has_column_privilege(a.attrelid, a.attnum, 'SELECT')
206
+ ORDER BY n.nspname, c.relname, a.attnum`, [schema ?? null, Object.keys(valueKinds)]);
207
+ // Group the columns that can be compared with the text by table.
208
+ const tables = new Map();
209
+ for (const row of found) {
210
+ const kind = valueKinds[row.udt ?? ''];
211
+ if (!kind || !kinds.has(kind))
212
+ continue;
213
+ const table = { schema: row.schema ?? '', name: row.table ?? '' };
214
+ const key = `${table.schema}\0${table.name}`;
215
+ const entry = tables.get(key) ?? { table, columns: [] };
216
+ entry.columns.push({ name: row.column ?? '', type: row.type ?? '', kind });
217
+ tables.set(key, entry);
218
+ }
219
+ const started = Date.now();
220
+ const queue = [...tables.values()];
221
+ // One slot per table, so the result reads the same however the sessions interleave.
222
+ const outcomes = [];
223
+ let next = 0;
224
+ // Tables being read or read successfully, against maxTables.
225
+ let used = 0;
226
+ // The timeout belongs to the session, so put the old one back when done.
227
+ const worker = async (session) => {
228
+ const [before] = await session.rows("SELECT current_setting('statement_timeout') AS timeout");
229
+ await session.rows("SELECT pg_catalog.set_config('statement_timeout', $1, false)", [
230
+ String(tableTimeoutMs),
231
+ ]);
232
+ try {
233
+ for (;;) {
234
+ const index = next++;
235
+ const item = queue[index];
236
+ if (!item)
237
+ return;
238
+ const { table, columns } = item;
239
+ if (used >= maxTables) {
240
+ outcomes[index] = { skipped: `only ${maxTables} tables are searched at a time` };
241
+ continue;
242
+ }
243
+ if (Date.now() - started > deadlineMs) {
244
+ outcomes[index] = { skipped: 'ran out of time' };
245
+ continue;
246
+ }
247
+ used++;
248
+ // One pass over the table counts the rows for every column at once.
249
+ const counts = columns
250
+ .map((column, i) => {
251
+ const condition = conditionFor(quoteIdentifier(column.name), column.kind, text);
252
+ return `count(*) FILTER (WHERE ${condition}) AS c${i}`;
253
+ })
254
+ .join(', ');
255
+ // A parameter nobody uses has no type, so it is only sent along with a text column.
256
+ const values = columns.some((column) => column.kind === 'text') ? [likePattern(text)] : [];
257
+ try {
258
+ const [row] = await session.rows(`SELECT ${counts} FROM ${qualified(table)}`, values);
259
+ outcomes[index] = {
260
+ matches: columns.flatMap((column, i) => {
261
+ const count = Number(row?.[`c${i}`] ?? 0);
262
+ return count > 0
263
+ ? [
264
+ {
265
+ ...table,
266
+ column: column.name,
267
+ type: column.type,
268
+ matches: count,
269
+ sql: valueSql(table, column.name, column.kind, text),
270
+ },
271
+ ]
272
+ : [];
273
+ }),
274
+ };
275
+ }
276
+ catch (error) {
277
+ used--;
278
+ outcomes[index] = {
279
+ skipped: timedOut(error)
280
+ ? `took longer than ${Math.round(tableTimeoutMs / 1000)}s`
281
+ : error instanceof Error
282
+ ? error.message
283
+ : String(error),
284
+ };
285
+ }
286
+ }
287
+ }
288
+ finally {
289
+ await session.rows("SELECT pg_catalog.set_config('statement_timeout', $1, false)", [
290
+ before?.timeout ?? '0',
291
+ ]);
292
+ }
293
+ };
294
+ await Promise.all(sessions.map(worker));
295
+ const matches = [];
296
+ const skipped = [];
297
+ let searched = 0;
298
+ outcomes.forEach((outcome, index) => {
299
+ if ('skipped' in outcome)
300
+ skipped.push({ table: queue[index].table, reason: outcome.skipped });
301
+ else {
302
+ searched++;
303
+ matches.push(...outcome.matches);
304
+ }
305
+ });
306
+ // Most matches first.
307
+ matches.sort((a, b) => b.matches - a.matches);
308
+ return { matches, searched, skipped };
309
+ }
@@ -0,0 +1,152 @@
1
+ const flag = (value) => value === 't';
2
+ const commands = {
3
+ '*': 'ALL',
4
+ r: 'SELECT',
5
+ a: 'INSERT',
6
+ w: 'UPDATE',
7
+ d: 'DELETE',
8
+ };
9
+ /** Roles of the server that skip row level security: superusers and those with BYPASSRLS. */
10
+ async function bypassRoles(session) {
11
+ const rows = await session.rows(`SELECT rolname AS name FROM pg_catalog.pg_roles
12
+ WHERE (rolsuper OR rolbypassrls) AND rolname !~ '^pg_' ORDER BY rolname`);
13
+ return rows.map((row) => row.name ?? '');
14
+ }
15
+ const relationKinds = {
16
+ r: 'table',
17
+ p: 'partitioned',
18
+ v: 'view',
19
+ m: 'materialized',
20
+ f: 'foreign',
21
+ };
22
+ /** RLS state, policies and grants of a table or view; null when there is no such relation. */
23
+ export async function describeSecurity(session, table) {
24
+ const [relation] = await session.rows(`SELECT c.oid::text AS oid, c.relkind AS kind, pg_catalog.pg_get_userbyid(c.relowner) AS owner,
25
+ c.relrowsecurity AS enabled, c.relforcerowsecurity AS forced,
26
+ coalesce(c.reloptions::text[] && ARRAY['security_invoker=true', 'security_invoker=on',
27
+ 'security_invoker=1', 'security_invoker=yes'], false) AS invoker
28
+ FROM pg_catalog.pg_class c
29
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
30
+ WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p', 'v', 'm', 'f')`, [table.schema, table.name]);
31
+ if (!relation?.oid)
32
+ return null;
33
+ const policies = await session.rows(`SELECT p.polname AS name, p.polcmd AS command, p.polpermissive AS permissive,
34
+ (SELECT coalesce(json_agg(CASE WHEN r = 0 THEN 'public'
35
+ ELSE pg_catalog.pg_get_userbyid(r) END), '[]')::text
36
+ FROM unnest(p.polroles) AS r) AS roles,
37
+ pg_catalog.pg_get_expr(p.polqual, p.polrelid) AS qual,
38
+ pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid) AS with_check
39
+ FROM pg_catalog.pg_policy p
40
+ WHERE p.polrelid = $1::oid
41
+ ORDER BY p.polname`, [relation.oid]);
42
+ const grants = await session.rows(`SELECT CASE WHEN a.grantee = 0 THEN 'public' ELSE pg_catalog.pg_get_userbyid(a.grantee) END
43
+ AS grantee,
44
+ json_agg(a.privilege_type ORDER BY a.privilege_type)::text AS privileges
45
+ FROM pg_catalog.pg_class c,
46
+ LATERAL aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) AS a
47
+ WHERE c.oid = $1::oid
48
+ GROUP BY a.grantee
49
+ ORDER BY a.grantee = 0 DESC, 1`, [relation.oid]);
50
+ const kind = relationKinds[relation.kind ?? ''] ?? 'table';
51
+ const sources = kind === 'view' || kind === 'materialized'
52
+ ? await session.rows(`SELECT DISTINCT n.nspname AS schema, t.relname AS name,
53
+ pg_catalog.pg_get_userbyid(t.relowner) AS owner,
54
+ t.relrowsecurity AS enabled, t.relforcerowsecurity AS forced,
55
+ (SELECT count(*) FROM pg_catalog.pg_policy p WHERE p.polrelid = t.oid) AS policies
56
+ FROM pg_catalog.pg_rewrite rw
57
+ JOIN pg_catalog.pg_depend d ON d.classid = 'pg_catalog.pg_rewrite'::regclass
58
+ AND d.objid = rw.oid AND d.refclassid = 'pg_catalog.pg_class'::regclass
59
+ JOIN pg_catalog.pg_class t ON t.oid = d.refobjid AND t.relkind IN ('r', 'p')
60
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
61
+ WHERE rw.ev_class = $1::oid AND t.oid <> rw.ev_class
62
+ ORDER BY 1, 2`, [relation.oid])
63
+ : [];
64
+ return {
65
+ kind,
66
+ owner: relation.owner ?? '',
67
+ securityInvoker: flag(relation.invoker),
68
+ sources: sources.map((row) => ({
69
+ schema: row.schema ?? '',
70
+ name: row.name ?? '',
71
+ owner: row.owner ?? '',
72
+ rlsEnabled: flag(row.enabled),
73
+ rlsForced: flag(row.forced),
74
+ policies: Number(row.policies),
75
+ })),
76
+ rlsEnabled: flag(relation.enabled),
77
+ rlsForced: flag(relation.forced),
78
+ policies: policies.map((row) => ({
79
+ name: row.name ?? '',
80
+ command: commands[row.command ?? ''] ?? 'ALL',
81
+ permissive: flag(row.permissive),
82
+ roles: JSON.parse(row.roles ?? '[]'),
83
+ using: row.qual,
84
+ withCheck: row.with_check,
85
+ })),
86
+ grants: grants.map((row) => ({
87
+ grantee: row.grantee ?? '',
88
+ privileges: JSON.parse(row.privileges ?? '[]'),
89
+ })),
90
+ bypassRoles: await bypassRoles(session).catch(() => []),
91
+ };
92
+ }
93
+ /** Who a function runs as and may be run by; null when there is no such function. */
94
+ export async function describeFunctionSecurity(session, ref) {
95
+ const [fn] = await session.rows(`SELECT p.oid::text AS oid, pg_catalog.pg_get_userbyid(p.proowner) AS owner,
96
+ p.prosecdef AS definer, p.proleakproof AS leakproof,
97
+ to_json(coalesce(p.proconfig, '{}'::text[]))::text AS config
98
+ FROM pg_catalog.pg_proc p
99
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
100
+ WHERE n.nspname = $1 AND p.proname = $2
101
+ AND pg_catalog.pg_get_function_identity_arguments(p.oid) = $3`, [ref.schema, ref.name, ref.args]);
102
+ if (!fn?.oid)
103
+ return null;
104
+ const grants = await session.rows(`SELECT CASE WHEN a.grantee = 0 THEN 'public' ELSE pg_catalog.pg_get_userbyid(a.grantee) END
105
+ AS grantee,
106
+ json_agg(a.privilege_type ORDER BY a.privilege_type)::text AS privileges
107
+ FROM pg_catalog.pg_proc p,
108
+ LATERAL aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) AS a
109
+ WHERE p.oid = $1::oid
110
+ GROUP BY a.grantee
111
+ ORDER BY a.grantee = 0 DESC, 1`, [fn.oid]);
112
+ return {
113
+ owner: fn.owner ?? '',
114
+ securityDefiner: flag(fn.definer),
115
+ leakproof: flag(fn.leakproof),
116
+ config: JSON.parse(fn.config ?? '[]'),
117
+ grants: grants.map((row) => ({
118
+ grantee: row.grantee ?? '',
119
+ privileges: JSON.parse(row.privileges ?? '[]'),
120
+ })),
121
+ bypassRoles: await bypassRoles(session).catch(() => []),
122
+ };
123
+ }
124
+ /** The roles of the server (not the built in pg_ ones) and how each stands against RLS. */
125
+ export async function listRoles(session) {
126
+ const rows = await session.rows(`SELECT r.rolname AS name, r.rolsuper AS superuser, r.rolcanlogin AS can_login,
127
+ r.rolcreaterole AS create_role, r.rolcreatedb AS create_db,
128
+ r.rolreplication AS replication, r.rolbypassrls AS bypass_rls,
129
+ r.rolconnlimit AS connection_limit,
130
+ (SELECT count(*) FROM pg_catalog.pg_policy p WHERE r.oid = ANY (p.polroles)) AS policies,
131
+ (SELECT count(*) FROM pg_catalog.pg_class c
132
+ WHERE c.relowner = r.oid AND c.relkind IN ('r', 'p') AND c.relrowsecurity) AS owned_rls,
133
+ (SELECT count(*) FROM pg_catalog.pg_class c
134
+ WHERE c.relowner = r.oid AND c.relkind IN ('r', 'p') AND c.relforcerowsecurity)
135
+ AS owned_forced
136
+ FROM pg_catalog.pg_roles r
137
+ WHERE r.rolname !~ '^pg_'
138
+ ORDER BY r.rolsuper DESC, r.rolname`);
139
+ return rows.map((row) => ({
140
+ name: row.name ?? '',
141
+ superuser: flag(row.superuser),
142
+ canLogin: flag(row.can_login),
143
+ createRole: flag(row.create_role),
144
+ createDb: flag(row.create_db),
145
+ replication: flag(row.replication),
146
+ bypassRls: flag(row.bypass_rls),
147
+ connectionLimit: Number(row.connection_limit),
148
+ policies: Number(row.policies),
149
+ ownedRlsTables: Number(row.owned_rls),
150
+ ownedForcedTables: Number(row.owned_forced),
151
+ }));
152
+ }