@zhin.js/database 1.1.8 → 1.2.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.
- package/lib/base/database.js +7 -0
- package/lib/base/statement-rows.d.ts +5 -0
- package/lib/base/statement-rows.js +16 -0
- package/lib/base/transaction.d.ts +3 -1
- package/lib/base/transaction.js +6 -0
- package/lib/console-credential.d.ts +181 -0
- package/lib/console-credential.js +633 -0
- package/lib/dialects/mongodb.js +7 -2
- package/lib/dialects/mysql.d.ts +34 -0
- package/lib/dialects/mysql.js +135 -13
- package/lib/dialects/pg.d.ts +19 -0
- package/lib/dialects/pg.js +81 -14
- package/lib/dialects/sqlite.d.ts +62 -2
- package/lib/dialects/sqlite.js +351 -14
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/reserved-tables.d.ts +10 -1
- package/lib/reserved-tables.js +44 -5
- package/lib/types.d.ts +16 -1
- package/package.json +1 -1
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The restricted database account of the Console SQL editor (ADR 0059 §4). On PostgreSQL and
|
|
3
|
+
* MySQL, raw SQL typed in the Console (`db:query` / `db:execute`) runs on its own connection
|
|
4
|
+
* that logs in as this account — never as the main account the instance uses. The account can
|
|
5
|
+
* read and change rows of the project schema and nothing else: no roles, no other databases,
|
|
6
|
+
* no server files, and on PostgreSQL no DDL (CVE-2018-1058-style objects planted for the main
|
|
7
|
+
* account). `SET [LOCAL] ROLE` is not a boundary (`RESET ROLE`, `set_config('role', …)`), so
|
|
8
|
+
* this is a separate login.
|
|
9
|
+
*
|
|
10
|
+
* Statements are built here once and used by `zhin db console-role` (run as the main account,
|
|
11
|
+
* or printed for a DBA with `--print-sql`), by the instance's startup probe and by the PostgreSQL
|
|
12
|
+
* grant refresh.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash, createHmac, pbkdf2Sync, randomBytes } from 'node:crypto';
|
|
15
|
+
import { isGatewayOwnedTable } from './reserved-tables.js';
|
|
16
|
+
/** Connections the restricted account may hold at once (the Console pool uses at most 2). */
|
|
17
|
+
export const CONSOLE_ROLE_CONNECTION_LIMIT = 4;
|
|
18
|
+
/** Role-level `statement_timeout` backstop (the Console sets a shorter one per transaction). */
|
|
19
|
+
export const CONSOLE_ROLE_STATEMENT_TIMEOUT = '60s';
|
|
20
|
+
/** Privileges of the PostgreSQL account on tables / sequences of `public`. */
|
|
21
|
+
export const PG_CONSOLE_TABLE_PRIVILEGES = 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE';
|
|
22
|
+
export const PG_CONSOLE_SEQUENCE_PRIVILEGES = 'USAGE, SELECT, UPDATE';
|
|
23
|
+
/** Privileges of the MySQL account on `<database>.*` (nothing global). */
|
|
24
|
+
export const MYSQL_CONSOLE_PRIVILEGES = Object.freeze([
|
|
25
|
+
'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'INDEX', 'REFERENCES',
|
|
26
|
+
'CREATE VIEW', 'SHOW VIEW', 'CREATE TEMPORARY TABLES',
|
|
27
|
+
]);
|
|
28
|
+
/** Schema privileges the MySQL account must never hold. */
|
|
29
|
+
export const MYSQL_FORBIDDEN_PRIVILEGES = new Set([
|
|
30
|
+
'TRIGGER', 'CREATE ROUTINE', 'ALTER ROUTINE', 'EXECUTE', 'EVENT', 'LOCK TABLES', 'GRANT OPTION',
|
|
31
|
+
'FILE', 'SUPER', 'PROCESS', 'RELOAD', 'SHUTDOWN', 'CREATE USER', 'REPLICATION SLAVE', 'REPLICATION CLIENT',
|
|
32
|
+
]);
|
|
33
|
+
const ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/u;
|
|
34
|
+
/**
|
|
35
|
+
* Role-level settings of the PostgreSQL account (`pg_db_role_setting`), exactly: the probe refuses
|
|
36
|
+
* an account whose settings differ — the account itself may `ALTER ROLE current_user SET …`
|
|
37
|
+
* (e.g. `search_path = pg_temp, public`), which would change how the main account's objects resolve
|
|
38
|
+
* in its sessions.
|
|
39
|
+
*/
|
|
40
|
+
export const PG_CONSOLE_ROLE_SETTINGS = Object.freeze([
|
|
41
|
+
`search_path=public`,
|
|
42
|
+
`statement_timeout=${CONSOLE_ROLE_STATEMENT_TIMEOUT}`,
|
|
43
|
+
]);
|
|
44
|
+
/** SCRAM-SHA-256 iteration count of the verifier sent instead of the password (PostgreSQL default). */
|
|
45
|
+
export const PG_SCRAM_ITERATIONS = 4096;
|
|
46
|
+
/**
|
|
47
|
+
* A PostgreSQL SCRAM-SHA-256 verifier (`SCRAM-SHA-256$<iter>:<salt>$<StoredKey>:<ServerKey>`, RFC
|
|
48
|
+
* 7677 / `pg_authid.rolpassword` format) computed on the client, so `CREATE` / `ALTER ROLE` never
|
|
49
|
+
* carries the password itself — not to the server log, not into `--print-sql` output. The password
|
|
50
|
+
* is base64url ASCII, so SASLprep leaves it unchanged.
|
|
51
|
+
*/
|
|
52
|
+
export function pgScramVerifier(password, salt = randomBytes(16), iterations = PG_SCRAM_ITERATIONS) {
|
|
53
|
+
const salted = pbkdf2Sync(password, salt, iterations, 32, 'sha256');
|
|
54
|
+
const clientKey = createHmac('sha256', salted).update('Client Key').digest();
|
|
55
|
+
const storedKey = createHash('sha256').update(clientKey).digest();
|
|
56
|
+
const serverKey = createHmac('sha256', salted).update('Server Key').digest();
|
|
57
|
+
return `SCRAM-SHA-256$${iterations}:${salt.toString('base64')}$${storedKey.toString('base64')}:${serverKey.toString('base64')}`;
|
|
58
|
+
}
|
|
59
|
+
/** Replaces every spelling of `secret` (raw, quoted, URL-encoded) in `text` with `[redacted]`. */
|
|
60
|
+
export function scrubSecret(text, secrets) {
|
|
61
|
+
let out = String(text);
|
|
62
|
+
for (const secret of secrets) {
|
|
63
|
+
if (!secret || secret.length < 4)
|
|
64
|
+
continue;
|
|
65
|
+
for (const form of new Set([secret, encodeURIComponent(secret), secret.replace(/'/gu, "''"), secret.replace(/\\/gu, '\\\\')])) {
|
|
66
|
+
out = out.split(form).join('[redacted]');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** An error with `secrets` scrubbed from its message (driver codes kept; the original is dropped). */
|
|
72
|
+
export function scrubbedError(error, secrets) {
|
|
73
|
+
const source = error;
|
|
74
|
+
const scrubbed = new Error(scrubSecret(String(source?.message ?? error), secrets));
|
|
75
|
+
for (const key of ['code', 'errno', 'sqlState']) {
|
|
76
|
+
if (source && (typeof source[key] === 'string' || typeof source[key] === 'number'))
|
|
77
|
+
Object.defineProperty(scrubbed, key, { value: source[key], enumerable: true });
|
|
78
|
+
}
|
|
79
|
+
return scrubbed;
|
|
80
|
+
}
|
|
81
|
+
/** `zhin_console_<first 8 hex of SHA-256(projectKey)>`: one account per project. */
|
|
82
|
+
export function consoleRoleName(projectKey) {
|
|
83
|
+
return `zhin_console_${createHash('sha256').update(projectKey).digest('hex').slice(0, 8)}`;
|
|
84
|
+
}
|
|
85
|
+
/** 32 random bytes, base64url (no quotes or backslashes: safe inside a SQL literal). */
|
|
86
|
+
export function generateConsolePassword() {
|
|
87
|
+
return randomBytes(32).toString('base64url');
|
|
88
|
+
}
|
|
89
|
+
function assertRoleName(role) {
|
|
90
|
+
if (!ROLE_NAME.test(role))
|
|
91
|
+
throw new TypeError(`Invalid console role name '${role}' (lowercase letters, digits and _; at most 63)`);
|
|
92
|
+
}
|
|
93
|
+
function assertPassword(password) {
|
|
94
|
+
if (!/^[A-Za-z0-9_-]{16,}$/u.test(password))
|
|
95
|
+
throw new TypeError('The console role password must be base64url (generateConsolePassword())');
|
|
96
|
+
}
|
|
97
|
+
export function quotePgIdentifier(name) {
|
|
98
|
+
return `"${String(name).replace(/"/gu, '""')}"`;
|
|
99
|
+
}
|
|
100
|
+
function pgLiteral(value) {
|
|
101
|
+
return `'${value.replace(/'/gu, "''")}'`;
|
|
102
|
+
}
|
|
103
|
+
function mysqlString(value) {
|
|
104
|
+
return `'${value.replace(/\\/gu, '\\\\').replace(/'/gu, "''")}'`;
|
|
105
|
+
}
|
|
106
|
+
function mysqlIdentifier(name) {
|
|
107
|
+
return `\`${name.replace(/`/gu, '``')}\``;
|
|
108
|
+
}
|
|
109
|
+
/** Database name in a GRANT: `_` and `%` are wildcards there, so they are escaped. */
|
|
110
|
+
function mysqlGrantDatabase(name) {
|
|
111
|
+
return mysqlIdentifier(name.replace(/[_%]/gu, character => `\\${character}`));
|
|
112
|
+
}
|
|
113
|
+
/** The statements that create (or rotate) the restricted account, in order. */
|
|
114
|
+
export function consoleRoleStatements(options) {
|
|
115
|
+
assertRoleName(options.role);
|
|
116
|
+
if (options.dialect === 'pg')
|
|
117
|
+
return pgRoleStatements(options);
|
|
118
|
+
return mysqlRoleStatements(options);
|
|
119
|
+
}
|
|
120
|
+
function pgRoleStatements(options) {
|
|
121
|
+
const role = quotePgIdentifier(options.role);
|
|
122
|
+
const main = quotePgIdentifier(options.mainUser);
|
|
123
|
+
// A literal SCRAM verifier: the server stores it as is, the password never travels.
|
|
124
|
+
const password = pgLiteral(options.password.startsWith('SCRAM-SHA-256$') || options.password.startsWith('<') ? options.password : pgScramVerifier(options.password));
|
|
125
|
+
// On ALTER, a CREATEROLE (non-superuser) account cannot even name SUPERUSER / REPLICATION /
|
|
126
|
+
// BYPASSRLS, nor CREATEDB unless it holds it (PG 16); their defaults are off, and the probe
|
|
127
|
+
// checks every attribute either way.
|
|
128
|
+
const privileged = !options.exists || options.mainIsSuperuser ? ' NOSUPERUSER NOREPLICATION NOBYPASSRLS' : '';
|
|
129
|
+
const createdb = !options.exists || options.mainIsSuperuser || options.mainCanCreateDb ? ' NOCREATEDB' : '';
|
|
130
|
+
const attributes = `LOGIN${privileged}${createdb} NOCREATEROLE NOINHERIT CONNECTION LIMIT ${CONSOLE_ROLE_CONNECTION_LIMIT} PASSWORD ${password}`;
|
|
131
|
+
const statements = [
|
|
132
|
+
options.exists ? `ALTER ROLE ${role} WITH ${attributes}` : `CREATE ROLE ${role} WITH ${attributes}`,
|
|
133
|
+
// Exactly the expected settings (PG_CONSOLE_ROLE_SETTINGS), whatever an earlier run left.
|
|
134
|
+
`ALTER ROLE ${role} RESET ALL`,
|
|
135
|
+
`ALTER ROLE ${role} SET statement_timeout = ${pgLiteral(CONSOLE_ROLE_STATEMENT_TIMEOUT)}`,
|
|
136
|
+
`ALTER ROLE ${role} SET search_path = public`,
|
|
137
|
+
`GRANT CONNECT ON DATABASE ${quotePgIdentifier(options.database)} TO ${role}`,
|
|
138
|
+
];
|
|
139
|
+
if (options.revokePublicCreate) {
|
|
140
|
+
statements.push(`GRANT CREATE ON SCHEMA public TO ${main}`, 'REVOKE CREATE ON SCHEMA public FROM PUBLIC');
|
|
141
|
+
}
|
|
142
|
+
statements.push(`REVOKE CREATE ON SCHEMA public FROM ${role}`, `GRANT USAGE ON SCHEMA public TO ${role}`, ...pgTableGrants(options.role, options.tables), `GRANT ${PG_CONSOLE_SEQUENCE_PRIVILEGES} ON ALL SEQUENCES IN SCHEMA public TO ${role}`, `ALTER DEFAULT PRIVILEGES FOR ROLE ${main} IN SCHEMA public GRANT ${PG_CONSOLE_TABLE_PRIVILEGES} ON TABLES TO ${role}`, `ALTER DEFAULT PRIVILEGES FOR ROLE ${main} IN SCHEMA public GRANT ${PG_CONSOLE_SEQUENCE_PRIVILEGES} ON SEQUENCES TO ${role}`);
|
|
143
|
+
return statements;
|
|
144
|
+
}
|
|
145
|
+
function pgTableGrants(roleName, tables) {
|
|
146
|
+
const role = quotePgIdentifier(roleName);
|
|
147
|
+
if (tables === undefined)
|
|
148
|
+
return [`GRANT ${PG_CONSOLE_TABLE_PRIVILEGES} ON ALL TABLES IN SCHEMA public TO ${role}`];
|
|
149
|
+
return tables.filter(table => !isGatewayOwnedTable(table))
|
|
150
|
+
.map(table => `GRANT ${PG_CONSOLE_TABLE_PRIVILEGES} ON TABLE public.${quotePgIdentifier(table)} TO ${role}`);
|
|
151
|
+
}
|
|
152
|
+
function mysqlRoleStatements(options) {
|
|
153
|
+
const account = `${mysqlString(options.role)}@${mysqlString(options.host ?? '%')}`;
|
|
154
|
+
const password = mysqlString(options.password);
|
|
155
|
+
return [
|
|
156
|
+
// MySQL ≥ 5.7.6 / MariaDB ≥ 10.1.3 (planConsoleRole refuses older servers).
|
|
157
|
+
`CREATE USER IF NOT EXISTS ${account} IDENTIFIED BY ${password} WITH MAX_USER_CONNECTIONS ${CONSOLE_ROLE_CONNECTION_LIMIT}`,
|
|
158
|
+
`ALTER USER ${account} IDENTIFIED BY ${password} WITH MAX_USER_CONNECTIONS ${CONSOLE_ROLE_CONNECTION_LIMIT}`,
|
|
159
|
+
// Back to a known state: an account reused from an earlier setup keeps no extra grant.
|
|
160
|
+
`REVOKE ALL PRIVILEGES, GRANT OPTION FROM ${account}`,
|
|
161
|
+
`GRANT ${MYSQL_CONSOLE_PRIVILEGES.join(', ')} ON ${mysqlGrantDatabase(options.database)}.* TO ${account}`,
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
function firstRow(result) {
|
|
165
|
+
return (Array.isArray(result) ? result[0] : undefined) ?? {};
|
|
166
|
+
}
|
|
167
|
+
function rows(result) {
|
|
168
|
+
return Array.isArray(result) ? result : [];
|
|
169
|
+
}
|
|
170
|
+
/** `user@host` of MySQL `CURRENT_USER()` (the user part may itself contain `@`). */
|
|
171
|
+
export function splitMysqlAccount(account) {
|
|
172
|
+
const at = account.lastIndexOf('@');
|
|
173
|
+
return at < 0 ? { user: account, host: '%' } : { user: account.slice(0, at), host: account.slice(at + 1) };
|
|
174
|
+
}
|
|
175
|
+
function truthy(value) {
|
|
176
|
+
return value === true || value === 1 || value === '1' || value === 't' || value === 'true';
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Reads what the statements need from the main account's connection (its name, the database,
|
|
180
|
+
* existing tables, whether the role exists) and returns the plan without running it
|
|
181
|
+
* (`--print-sql`).
|
|
182
|
+
*/
|
|
183
|
+
export async function planConsoleRole(run, options) {
|
|
184
|
+
const role = options.role ?? consoleRoleName(options.projectKey);
|
|
185
|
+
assertRoleName(role);
|
|
186
|
+
const password = options.password ?? generateConsolePassword();
|
|
187
|
+
if (options.dialect === 'pg') {
|
|
188
|
+
const facts = firstRow(await run(`SELECT current_user AS main, current_database() AS database,
|
|
189
|
+
(SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS super,
|
|
190
|
+
(SELECT rolcreatedb FROM pg_roles WHERE rolname = current_user) AS createdb,
|
|
191
|
+
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1) AS present,
|
|
192
|
+
EXISTS (SELECT 1 FROM pg_namespace n, aclexplode(n.nspacl) a WHERE n.nspname = 'public' AND a.grantee = 0 AND a.privilege_type = 'CREATE') AS public_create`, [role]));
|
|
193
|
+
const tables = rows(await run(`SELECT c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
194
|
+
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p', 'v', 'm', 'f') ORDER BY c.relname`)).map(row => String(row.name));
|
|
195
|
+
const mainUser = String(facts.main);
|
|
196
|
+
const database = String(facts.database);
|
|
197
|
+
return Object.freeze({
|
|
198
|
+
dialect: 'pg', user: role, password, database, mainUser,
|
|
199
|
+
statements: Object.freeze(consoleRoleStatements({
|
|
200
|
+
dialect: 'pg', role, password, database, mainUser, tables,
|
|
201
|
+
exists: truthy(facts.present), mainIsSuperuser: truthy(facts.super), mainCanCreateDb: truthy(facts.createdb), revokePublicCreate: truthy(facts.public_create),
|
|
202
|
+
})),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const facts = firstRow(await run('SELECT CURRENT_USER() AS main, DATABASE() AS db, VERSION() AS version'));
|
|
206
|
+
const database = facts.db == null ? '' : String(facts.db);
|
|
207
|
+
if (!database)
|
|
208
|
+
throw new Error('The MySQL connection has no default database (database.database)');
|
|
209
|
+
const unsupported = mysqlVersionProblem(String(facts.version ?? ''));
|
|
210
|
+
if (unsupported)
|
|
211
|
+
throw new Error(unsupported);
|
|
212
|
+
const main = splitMysqlAccount(String(facts.main));
|
|
213
|
+
return Object.freeze({
|
|
214
|
+
dialect: 'mysql', user: role, password, host: main.host, database, mainUser: main.user,
|
|
215
|
+
statements: Object.freeze(consoleRoleStatements({ dialect: 'mysql', role, password, database, mainUser: main.user, host: main.host })),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Creates (or rotates the password of) the restricted account with the main account's
|
|
220
|
+
* connection. Needs CREATEROLE (PostgreSQL) or CREATE USER + GRANT OPTION (MySQL); without them
|
|
221
|
+
* the first statement fails and the caller offers `--print-sql`.
|
|
222
|
+
*/
|
|
223
|
+
export async function provisionConsoleRole(run, options) {
|
|
224
|
+
const plan = await planConsoleRole(run, options);
|
|
225
|
+
assertPassword(plan.password);
|
|
226
|
+
try {
|
|
227
|
+
for (const statement of plan.statements)
|
|
228
|
+
await run(statement);
|
|
229
|
+
if (plan.dialect === 'pg')
|
|
230
|
+
await refreshConsoleGrants(run, { role: plan.user });
|
|
231
|
+
else
|
|
232
|
+
await revokeMysqlRolesAndProxies(run, plan.user, plan.host ?? '%');
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
// A driver error may quote the statement (mysql2 keeps `sql`): the password never leaves here.
|
|
236
|
+
throw scrubbedError(error, [plan.password]);
|
|
237
|
+
}
|
|
238
|
+
return plan;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Version check of `CREATE USER IF NOT EXISTS` / `ALTER USER … IDENTIFIED BY` (MySQL 5.7.6,
|
|
242
|
+
* MariaDB 10.1.3); `undefined` when supported.
|
|
243
|
+
*/
|
|
244
|
+
export function mysqlVersionProblem(version) {
|
|
245
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/u.exec(version);
|
|
246
|
+
if (!match)
|
|
247
|
+
return undefined;
|
|
248
|
+
const [major, minor, patch] = match.slice(1).map(Number);
|
|
249
|
+
const at = (a, b, c) => major > a || (major === a && (minor > b || (minor === b && patch >= c)));
|
|
250
|
+
const mariadb = /mariadb/iu.test(version);
|
|
251
|
+
const ok = mariadb ? at(10, 1, 3) : at(5, 7, 6);
|
|
252
|
+
return ok ? undefined : `${mariadb ? 'MariaDB' : 'MySQL'} ${version} is too old for the restricted Console account (needs ${mariadb ? 'MariaDB 10.1.3' : 'MySQL 5.7.6'}+ for CREATE USER IF NOT EXISTS / ALTER USER)`;
|
|
253
|
+
}
|
|
254
|
+
/** Splits a `SHOW GRANTS` line into words, quoted names, `@` and `,` in one linear pass (no regex backtracking). */
|
|
255
|
+
function grantTokens(line) {
|
|
256
|
+
const tokens = [];
|
|
257
|
+
let index = 0;
|
|
258
|
+
while (index < line.length) {
|
|
259
|
+
const char = line[index];
|
|
260
|
+
if (char === ' ' || char === '\t' || char === '\n' || char === '\r') {
|
|
261
|
+
index += 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (char === '`' || char === "'" || char === '"') {
|
|
265
|
+
let end = index + 1;
|
|
266
|
+
while (end < line.length && line[end] !== char)
|
|
267
|
+
end += 1;
|
|
268
|
+
if (end >= line.length)
|
|
269
|
+
return undefined; // unterminated quote: not a line we understand
|
|
270
|
+
tokens.push({ kind: 'quoted', text: line.slice(index, end + 1), start: index, end: end + 1 });
|
|
271
|
+
index = end + 1;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (char === '@' || char === ',') {
|
|
275
|
+
tokens.push({ kind: char === '@' ? 'at' : 'comma', text: char, start: index, end: index + 1 });
|
|
276
|
+
index += 1;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
let end = index;
|
|
280
|
+
while (end < line.length && !' \t\n\r`\'"@,'.includes(line[end]))
|
|
281
|
+
end += 1;
|
|
282
|
+
tokens.push({ kind: 'word', text: line.slice(index, end), start: index, end });
|
|
283
|
+
index = end;
|
|
284
|
+
}
|
|
285
|
+
return tokens;
|
|
286
|
+
}
|
|
287
|
+
const isWord = (token, word) => token?.kind === 'word' && token.text.toUpperCase() === word;
|
|
288
|
+
/** Role grants and PROXY grants in `SHOW GRANTS` output (`GRANT \`r\`@\`%\` TO …`, `GRANT PROXY ON …`). */
|
|
289
|
+
export function mysqlRoleAndProxyGrants(lines) {
|
|
290
|
+
const roles = [];
|
|
291
|
+
const proxies = [];
|
|
292
|
+
for (const line of lines) {
|
|
293
|
+
const tokens = grantTokens(line);
|
|
294
|
+
if (!tokens || !isWord(tokens[0], 'GRANT'))
|
|
295
|
+
continue;
|
|
296
|
+
const to = tokens.findIndex((token, index) => index > 0 && isWord(token, 'TO'));
|
|
297
|
+
if (to < 0)
|
|
298
|
+
continue;
|
|
299
|
+
if (isWord(tokens[1], 'PROXY') && isWord(tokens[2], 'ON')) {
|
|
300
|
+
if (to > 3)
|
|
301
|
+
proxies.push(line.slice(tokens[3].start, tokens[to - 1].end));
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// A role grant has no ON clause and lists only quoted names: GRANT `r1`@`%`,`r2`@`%` TO `u`@`h`
|
|
305
|
+
// (MariaDB: GRANT `r1` TO …, no host).
|
|
306
|
+
const names = tokens.slice(1, to);
|
|
307
|
+
if (names.length === 0 || names.some(token => token.kind === 'word'))
|
|
308
|
+
continue;
|
|
309
|
+
let entryStart;
|
|
310
|
+
let entryEnd = 0;
|
|
311
|
+
const flush = () => { if (entryStart !== undefined)
|
|
312
|
+
roles.push(line.slice(entryStart, entryEnd)); entryStart = undefined; };
|
|
313
|
+
for (const token of names) {
|
|
314
|
+
if (token.kind === 'comma') {
|
|
315
|
+
flush();
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (entryStart === undefined)
|
|
319
|
+
entryStart = token.start;
|
|
320
|
+
entryEnd = token.end;
|
|
321
|
+
}
|
|
322
|
+
flush();
|
|
323
|
+
}
|
|
324
|
+
return Object.freeze({ roles: Object.freeze(roles), proxies: Object.freeze(proxies) });
|
|
325
|
+
}
|
|
326
|
+
function grantLines(result) {
|
|
327
|
+
return rows(result).map(row => String(Object.values(row)[0] ?? ''));
|
|
328
|
+
}
|
|
329
|
+
/** MySQL: revokes roles and PROXY grants the account may still hold (REVOKE ALL PRIVILEGES does not). */
|
|
330
|
+
async function revokeMysqlRolesAndProxies(run, user, host) {
|
|
331
|
+
const account = `${mysqlString(user)}@${mysqlString(host)}`;
|
|
332
|
+
let lines;
|
|
333
|
+
try {
|
|
334
|
+
lines = grantLines(await run(`SHOW GRANTS FOR ${account}`));
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
return; // The probe still refuses an account with roles or PROXY grants.
|
|
338
|
+
}
|
|
339
|
+
const { roles, proxies } = mysqlRoleAndProxyGrants(lines);
|
|
340
|
+
for (const role of roles)
|
|
341
|
+
await run(`REVOKE ${role} FROM ${account}`);
|
|
342
|
+
for (const proxy of proxies)
|
|
343
|
+
await run(`REVOKE PROXY ON ${proxy} FROM ${account}`);
|
|
344
|
+
}
|
|
345
|
+
/** How many SECURITY DEFINER functions a probe names in its log details. */
|
|
346
|
+
export const PG_DEFINER_FUNCTIONS_LOGGED = 5;
|
|
347
|
+
/**
|
|
348
|
+
* The account may EXECUTE a SECURITY DEFINER function owned by another role outside `pg_catalog` /
|
|
349
|
+
* `information_schema` (extension functions in other schemas included): it would run with that
|
|
350
|
+
* owner's rights. PUBLIC has EXECUTE on functions by default, so provisioning cannot fix this
|
|
351
|
+
* without changing everyone's access — the owner revokes it (or grants it explicitly) instead.
|
|
352
|
+
*/
|
|
353
|
+
export const PG_DEFINER_FUNCTION_PROBLEM = 'the console user can execute SECURITY DEFINER functions owned by other roles (they run with the owner\'s rights): '
|
|
354
|
+
+ 'REVOKE EXECUTE ON FUNCTION <schema>.<function>(…) FROM PUBLIC, or grant EXECUTE only to the roles that need it; the instance log names the functions';
|
|
355
|
+
/**
|
|
356
|
+
* Functions that read or write server files or reach other servers (`pg_ls_*`, `pg_read_file`,
|
|
357
|
+
* `pg_read_binary_file`, `pg_stat_file`, server-side `lo_import` / `lo_export`, adminpack
|
|
358
|
+
* `pg_file_*`, `dblink*`): none of them may be executable by the console account (they are
|
|
359
|
+
* revoked from PUBLIC by default; an explicit grant or an extension that grants them is refused).
|
|
360
|
+
*/
|
|
361
|
+
export const PG_FILE_FUNCTIONS_QUERY = `SELECT DISTINCT p.proname AS name FROM pg_proc p
|
|
362
|
+
WHERE (p.proname LIKE 'pg\\_ls\\_%' OR p.proname IN ('pg_read_file', 'pg_read_binary_file', 'pg_stat_file', 'lo_import', 'lo_export')
|
|
363
|
+
OR p.proname LIKE 'pg\\_file\\_%' OR p.proname LIKE 'dblink%')
|
|
364
|
+
AND has_function_privilege(current_user, p.oid, 'EXECUTE')
|
|
365
|
+
ORDER BY 1`;
|
|
366
|
+
/** The query behind {@link PG_DEFINER_FUNCTION_PROBLEM} (at most PG_DEFINER_FUNCTIONS_LOGGED + 1 rows). */
|
|
367
|
+
export const PG_DEFINER_FUNCTIONS_QUERY = `SELECT n.nspname AS schema, p.proname AS name FROM pg_proc p
|
|
368
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
369
|
+
WHERE p.prosecdef AND p.proowner <> (SELECT oid FROM pg_roles WHERE rolname = current_user)
|
|
370
|
+
AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg\\_temp\\_%' AND n.nspname NOT LIKE 'pg\\_toast\\_temp\\_%'
|
|
371
|
+
AND has_function_privilege(current_user, p.oid, 'EXECUTE')
|
|
372
|
+
ORDER BY 1, 2 LIMIT ${PG_DEFINER_FUNCTIONS_LOGGED + 1}`;
|
|
373
|
+
/**
|
|
374
|
+
* Checks, on the restricted connection itself, that it is the configured account and holds no
|
|
375
|
+
* more than {@link consoleRoleStatements} grants. Raw Console SQL stays off unless `ok`.
|
|
376
|
+
*/
|
|
377
|
+
export async function probeConsoleCredential(run, options) {
|
|
378
|
+
return options.dialect === 'pg' ? probePg(run, options) : probeMysql(run, options);
|
|
379
|
+
}
|
|
380
|
+
async function probePg(run, options) {
|
|
381
|
+
const problems = [];
|
|
382
|
+
const facts = firstRow(await run(`SELECT current_user AS "user", session_user AS "session", r.rolsuper AS super, r.rolcreaterole AS createrole,
|
|
383
|
+
r.rolcreatedb AS createdb, r.rolbypassrls AS bypassrls, r.rolreplication AS replication,
|
|
384
|
+
has_schema_privilege(current_user, 'public', 'CREATE') AS schema_create,
|
|
385
|
+
has_database_privilege(current_user, current_database(), 'CREATE') AS database_create,
|
|
386
|
+
(SELECT count(*) FROM pg_auth_members m WHERE m.member = r.oid)::int AS memberships
|
|
387
|
+
FROM pg_roles r WHERE r.rolname = current_user`));
|
|
388
|
+
const user = String(facts.user ?? '');
|
|
389
|
+
if (user !== options.expectedUser || String(facts.session ?? '') !== options.expectedUser) {
|
|
390
|
+
problems.push(`the connection logged in as '${user}', not the configured console user '${options.expectedUser}'`);
|
|
391
|
+
}
|
|
392
|
+
if (options.mainUser && user === options.mainUser)
|
|
393
|
+
problems.push('the console user is the main account');
|
|
394
|
+
if (truthy(facts.super))
|
|
395
|
+
problems.push('the console user is a superuser');
|
|
396
|
+
if (truthy(facts.createrole))
|
|
397
|
+
problems.push('the console user has CREATEROLE');
|
|
398
|
+
if (truthy(facts.createdb))
|
|
399
|
+
problems.push('the console user has CREATEDB');
|
|
400
|
+
if (truthy(facts.bypassrls))
|
|
401
|
+
problems.push('the console user has BYPASSRLS');
|
|
402
|
+
if (truthy(facts.replication))
|
|
403
|
+
problems.push('the console user has REPLICATION');
|
|
404
|
+
if (truthy(facts.schema_create)) {
|
|
405
|
+
problems.push('the console user may CREATE in schema public (PostgreSQL ≤ 14 grants it to PUBLIC: REVOKE CREATE ON SCHEMA public FROM PUBLIC)');
|
|
406
|
+
}
|
|
407
|
+
if (truthy(facts.database_create))
|
|
408
|
+
problems.push('the console user may CREATE schemas in the database');
|
|
409
|
+
if (Number(facts.memberships ?? 0) > 0)
|
|
410
|
+
problems.push('the console user is a member of other roles');
|
|
411
|
+
if (options.mainUser && options.mainUser !== user) {
|
|
412
|
+
const member = firstRow(await run(`SELECT pg_has_role(current_user, oid, 'USAGE') AS usage FROM pg_roles WHERE rolname = $1`, [options.mainUser]));
|
|
413
|
+
if (truthy(member.usage))
|
|
414
|
+
problems.push(`the console user can use the privileges of the main account '${options.mainUser}'`);
|
|
415
|
+
}
|
|
416
|
+
// CVE-2018-1058 preconditions: objects the main account could resolve to in a schema the console
|
|
417
|
+
// user may create in, or owns already (a schema named after the main account comes first in
|
|
418
|
+
// its default search_path "$user", public).
|
|
419
|
+
const temp = `n.nspname NOT LIKE 'pg\\_temp\\_%' AND n.nspname NOT LIKE 'pg\\_toast\\_temp\\_%'`;
|
|
420
|
+
const creatable = rows(await run(`SELECT n.nspname AS name FROM pg_namespace n WHERE has_schema_privilege(current_user, n.oid, 'CREATE') AND ${temp} ORDER BY 1`)).map(row => String(row.name));
|
|
421
|
+
if (creatable.length)
|
|
422
|
+
problems.push(`the console user may CREATE in schema ${creatable.join(', ')}`);
|
|
423
|
+
const owned = firstRow(await run(`SELECT
|
|
424
|
+
(SELECT count(*) FROM pg_namespace n WHERE n.nspowner = r.oid AND ${temp})::int AS schemas,
|
|
425
|
+
(SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relowner = r.oid AND ${temp})::int AS relations,
|
|
426
|
+
(SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE p.proowner = r.oid AND ${temp})::int AS functions,
|
|
427
|
+
(SELECT count(*) FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typowner = r.oid AND ${temp})::int AS types,
|
|
428
|
+
(SELECT count(*) FROM pg_operator o JOIN pg_namespace n ON n.oid = o.oprnamespace WHERE o.oprowner = r.oid AND ${temp})::int AS operators
|
|
429
|
+
FROM pg_roles r WHERE r.rolname = current_user`));
|
|
430
|
+
const ownedKinds = ['schemas', 'relations', 'functions', 'types', 'operators'].filter(kind => Number(owned[kind] ?? 0) > 0);
|
|
431
|
+
if (ownedKinds.length)
|
|
432
|
+
problems.push(`the console user owns objects (${ownedKinds.map(kind => `${kind}: ${owned[kind]}`).join(', ')})`);
|
|
433
|
+
const settings = rows(await run(`SELECT unnest(s.setconfig) AS setting FROM pg_db_role_setting s JOIN pg_roles r ON r.oid = s.setrole
|
|
434
|
+
WHERE r.rolname = current_user AND s.setdatabase IN (0, (SELECT oid FROM pg_database WHERE datname = current_database()))`)).map(row => String(row.setting));
|
|
435
|
+
const expected = [...PG_CONSOLE_ROLE_SETTINGS].sort();
|
|
436
|
+
if (JSON.stringify([...settings].sort()) !== JSON.stringify(expected)) {
|
|
437
|
+
problems.push(`the console user's role settings are ${JSON.stringify(settings)}, expected ${JSON.stringify(expected)}`);
|
|
438
|
+
}
|
|
439
|
+
// Column-level grants count too (GRANT UPDATE (store_id) ON zhin_gateway_store …).
|
|
440
|
+
const gatewayTables = rows(await run(`SELECT c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
441
|
+
WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f') AND (has_table_privilege(current_user, c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER')
|
|
442
|
+
OR has_any_column_privilege(current_user, c.oid, 'SELECT, INSERT, UPDATE, REFERENCES'))`))
|
|
443
|
+
.map(row => String(row.name)).filter(name => isGatewayOwnedTable(name));
|
|
444
|
+
if (gatewayTables.length)
|
|
445
|
+
problems.push(`the console user can reach gateway tables: ${gatewayTables.join(', ')}`);
|
|
446
|
+
// Grants beyond the project schema (another schema's tables / views it can use).
|
|
447
|
+
const elsewhere = rows(await run(`SELECT DISTINCT n.nspname AS schema FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
448
|
+
WHERE n.nspname NOT IN ('public', 'pg_catalog', 'information_schema') AND ${temp} AND n.nspname <> 'pg_toast'
|
|
449
|
+
AND c.relkind IN ('r', 'p', 'v', 'm', 'f') AND has_schema_privilege(current_user, n.oid, 'USAGE')
|
|
450
|
+
AND (has_table_privilege(current_user, c.oid, 'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER')
|
|
451
|
+
OR has_any_column_privilege(current_user, c.oid, 'SELECT, INSERT, UPDATE, REFERENCES'))
|
|
452
|
+
ORDER BY 1 LIMIT 5`)).map(row => String(row.schema));
|
|
453
|
+
if (elsewhere.length)
|
|
454
|
+
problems.push(`the console user can use tables outside schema public: ${elsewhere.join(', ')}`);
|
|
455
|
+
const fileFunctions = rows(await run(PG_FILE_FUNCTIONS_QUERY)).map(row => String(row.name));
|
|
456
|
+
if (fileFunctions.length)
|
|
457
|
+
problems.push(`the console user may execute server file / remote functions: ${[...new Set(fileFunctions)].join(', ')}`);
|
|
458
|
+
const largeObjects = firstRow(await run(`SELECT count(*)::int AS n FROM pg_largeobject_metadata WHERE lomowner = (SELECT oid FROM pg_roles WHERE rolname = current_user)`));
|
|
459
|
+
if (Number(largeObjects.n ?? 0) > 0)
|
|
460
|
+
problems.push(`the console user owns large objects (${largeObjects.n})`);
|
|
461
|
+
const definers = rows(await run(PG_DEFINER_FUNCTIONS_QUERY)).map(row => `${String(row.schema)}.${String(row.name)}`);
|
|
462
|
+
const details = [];
|
|
463
|
+
if (definers.length) {
|
|
464
|
+
problems.push(PG_DEFINER_FUNCTION_PROBLEM);
|
|
465
|
+
const named = definers.slice(0, PG_DEFINER_FUNCTIONS_LOGGED);
|
|
466
|
+
details.push(`SECURITY DEFINER functions executable by the console user: ${named.join(', ')}${definers.length > named.length ? ', …' : ''}`);
|
|
467
|
+
}
|
|
468
|
+
return Object.freeze({ ok: problems.length === 0, problems: Object.freeze(problems), user, ...(details.length ? { details: Object.freeze(details) } : {}) });
|
|
469
|
+
}
|
|
470
|
+
async function probeMysql(run, options) {
|
|
471
|
+
const problems = [];
|
|
472
|
+
const facts = firstRow(await run('SELECT CURRENT_USER() AS account, DATABASE() AS db'));
|
|
473
|
+
const account = splitMysqlAccount(String(facts.account ?? ''));
|
|
474
|
+
const database = facts.db == null ? '' : String(facts.db);
|
|
475
|
+
if (account.user !== options.expectedUser) {
|
|
476
|
+
problems.push(`the connection logged in as '${account.user}', not the configured console user '${options.expectedUser}'`);
|
|
477
|
+
}
|
|
478
|
+
if (options.mainUser && account.user === options.mainUser)
|
|
479
|
+
problems.push('the console user is the main account');
|
|
480
|
+
const grantee = `${mysqlString(account.user)}@${mysqlString(account.host)}`;
|
|
481
|
+
const global = rows(await run('SELECT PRIVILEGE_TYPE AS privilege, IS_GRANTABLE AS grantable FROM information_schema.USER_PRIVILEGES WHERE GRANTEE = ?', [grantee]));
|
|
482
|
+
const extraGlobal = global.map(row => String(row.privilege).toUpperCase()).filter(privilege => privilege !== 'USAGE');
|
|
483
|
+
if (extraGlobal.length)
|
|
484
|
+
problems.push(`the console user holds global privileges: ${extraGlobal.join(', ')}`);
|
|
485
|
+
const schema = rows(await run('SELECT TABLE_SCHEMA AS db, PRIVILEGE_TYPE AS privilege, IS_GRANTABLE AS grantable FROM information_schema.SCHEMA_PRIVILEGES WHERE GRANTEE = ?', [grantee]));
|
|
486
|
+
const unescape = (name) => name.replace(/\\([_%])/gu, '$1');
|
|
487
|
+
const elsewhere = [...new Set(schema.map(row => unescape(String(row.db))).filter(name => name !== database))];
|
|
488
|
+
if (elsewhere.length)
|
|
489
|
+
problems.push(`the console user holds privileges on other databases: ${elsewhere.join(', ')}`);
|
|
490
|
+
const forbidden = [...new Set(schema.map(row => String(row.privilege).toUpperCase()).filter(privilege => MYSQL_FORBIDDEN_PRIVILEGES.has(privilege)))];
|
|
491
|
+
if (forbidden.length)
|
|
492
|
+
problems.push(`the console user holds ${forbidden.join(', ')}`);
|
|
493
|
+
if ([...global, ...schema].some(row => String(row.grantable).toUpperCase() === 'YES'))
|
|
494
|
+
problems.push('the console user holds GRANT OPTION');
|
|
495
|
+
const tables = firstRow(await run('SELECT COUNT(*) AS n FROM information_schema.TABLE_PRIVILEGES WHERE GRANTEE = ? AND TABLE_SCHEMA <> DATABASE()', [grantee]));
|
|
496
|
+
if (Number(tables.n ?? 0) > 0)
|
|
497
|
+
problems.push('the console user holds table privileges on other databases');
|
|
498
|
+
const allowed = new Set(MYSQL_CONSOLE_PRIVILEGES);
|
|
499
|
+
const fine = [
|
|
500
|
+
...rows(await run('SELECT TABLE_SCHEMA AS db, PRIVILEGE_TYPE AS privilege, IS_GRANTABLE AS grantable FROM information_schema.TABLE_PRIVILEGES WHERE GRANTEE = ?', [grantee])),
|
|
501
|
+
...rows(await run('SELECT TABLE_SCHEMA AS db, PRIVILEGE_TYPE AS privilege, IS_GRANTABLE AS grantable FROM information_schema.COLUMN_PRIVILEGES WHERE GRANTEE = ?', [grantee])),
|
|
502
|
+
];
|
|
503
|
+
const outside = [...new Set(fine.filter(row => unescape(String(row.db)) !== database || !allowed.has(String(row.privilege).toUpperCase()) || String(row.grantable).toUpperCase() === 'YES')
|
|
504
|
+
.map(row => `${String(row.privilege).toUpperCase()} on ${unescape(String(row.db))}`))];
|
|
505
|
+
if (outside.length)
|
|
506
|
+
problems.push(`the console user holds table / column privileges outside its grant: ${outside.join(', ')}`);
|
|
507
|
+
try {
|
|
508
|
+
const applicable = firstRow(await run('SELECT COUNT(*) AS n FROM information_schema.APPLICABLE_ROLES'));
|
|
509
|
+
if (Number(applicable.n ?? 0) > 0)
|
|
510
|
+
problems.push('the console user has granted roles (information_schema.APPLICABLE_ROLES)');
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Before MySQL 8.0.19 / MariaDB: SHOW GRANTS below lists role grants.
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
const { roles, proxies } = mysqlRoleAndProxyGrants(grantLines(await run('SHOW GRANTS')));
|
|
517
|
+
if (roles.length)
|
|
518
|
+
problems.push(`the console user has granted roles: ${roles.join(', ')}`);
|
|
519
|
+
if (proxies.length)
|
|
520
|
+
problems.push(`the console user holds PROXY grants: ${proxies.join(', ')}`);
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
problems.push(`SHOW GRANTS failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
524
|
+
}
|
|
525
|
+
try {
|
|
526
|
+
const role = firstRow(await run('SELECT CURRENT_ROLE() AS role'));
|
|
527
|
+
const active = String(role.role ?? 'NONE');
|
|
528
|
+
if (active !== 'NONE' && active !== '')
|
|
529
|
+
problems.push(`the console user has active roles: ${active}`);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// MySQL 5.7 / MariaDB: no CURRENT_ROLE(); roles are not in use there.
|
|
533
|
+
}
|
|
534
|
+
return Object.freeze({ ok: problems.length === 0, problems: Object.freeze(problems), user: account.user });
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* PostgreSQL, main account: grants the account the table / sequence privileges on what exists in
|
|
538
|
+
* `public` now (tables created before the default privileges, or by another role the main
|
|
539
|
+
* account can grant for), and revokes everything on gateway-owned (`console_*`) tables that are
|
|
540
|
+
* still in the project database. Idempotent; run at instance start.
|
|
541
|
+
*/
|
|
542
|
+
export async function refreshConsoleGrants(run, options) {
|
|
543
|
+
assertRoleName(options.role);
|
|
544
|
+
const role = quotePgIdentifier(options.role);
|
|
545
|
+
const present = rows(await run(`SELECT c.relname AS name, pg_get_userbyid(c.relowner) = current_user OR has_table_privilege(c.oid, 'SELECT WITH GRANT OPTION') AS grantable
|
|
546
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
547
|
+
WHERE n.nspname = 'public' AND c.relkind IN ('r', 'p', 'v', 'm', 'f') ORDER BY c.relname`));
|
|
548
|
+
const granted = [];
|
|
549
|
+
const revoked = [];
|
|
550
|
+
for (const row of present) {
|
|
551
|
+
const name = String(row.name);
|
|
552
|
+
const table = `public.${quotePgIdentifier(name)}`;
|
|
553
|
+
if (isGatewayOwnedTable(name)) {
|
|
554
|
+
if (truthy(row.grantable)) {
|
|
555
|
+
await run(`REVOKE ALL ON TABLE ${table} FROM ${role}`);
|
|
556
|
+
revoked.push(name);
|
|
557
|
+
}
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (!truthy(row.grantable))
|
|
561
|
+
continue;
|
|
562
|
+
await run(`GRANT ${PG_CONSOLE_TABLE_PRIVILEGES} ON TABLE ${table} TO ${role}`);
|
|
563
|
+
granted.push(name);
|
|
564
|
+
}
|
|
565
|
+
await run(`GRANT ${PG_CONSOLE_SEQUENCE_PRIVILEGES} ON ALL SEQUENCES IN SCHEMA public TO ${role}`);
|
|
566
|
+
return Object.freeze({ granted: Object.freeze(granted), revoked: Object.freeze(revoked) });
|
|
567
|
+
}
|
|
568
|
+
/** Query parameters of a MySQL `uri` the restricted connection keeps (no credentials, flags or statements switches). */
|
|
569
|
+
const MYSQL_URI_KEPT_PARAMS = new Set(['charset', 'timezone', 'connectTimeout', 'ssl', 'supportBigNumbers', 'bigNumberStrings', 'dateStrings', 'decimalNumbers']);
|
|
570
|
+
/**
|
|
571
|
+
* Connection options of the restricted Console account, built from the main connection's options
|
|
572
|
+
* (ADR 0059 §4): the main account's credentials never reach it — neither as fields nor inside a
|
|
573
|
+
* PostgreSQL `connectionString` (whose `user` / `password`, also as query parameters, would win over
|
|
574
|
+
* explicit fields, empty ones included) nor a MySQL `uri` (whose query parameters fill every falsy
|
|
575
|
+
* field: `?multipleStatements=true` would beat `false`, `?flags=LOCAL_FILES` would re-enable LOCAL
|
|
576
|
+
* INFILE). Pool of at most two connections, session reset on release; MySQL without
|
|
577
|
+
* `LOCAL_FILES` and multiple statements.
|
|
578
|
+
*/
|
|
579
|
+
export function consoleConnectionOptions(dialect, main, credential) {
|
|
580
|
+
const { user: _user, password: _password, password1: _password1, password2: _password2, password3: _password3, passwordSha1: _passwordSha1, connectionString, uri, pool: _pool, options: _options, flags: _flags, multipleStatements: _multiple, infileStreamFactory: _infile, localInfile: _localInfile, resetSession: _reset, console: _console, ...rest } = main;
|
|
581
|
+
const base = { ...rest };
|
|
582
|
+
if (dialect === 'pg' && typeof connectionString === 'string') {
|
|
583
|
+
let url;
|
|
584
|
+
try {
|
|
585
|
+
url = new URL(connectionString);
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
throw new TypeError('database.connectionString is not a URL; the restricted Console account cannot reuse it');
|
|
589
|
+
}
|
|
590
|
+
url.username = '';
|
|
591
|
+
url.password = '';
|
|
592
|
+
for (const key of ['user', 'password', 'options'])
|
|
593
|
+
url.searchParams.delete(key);
|
|
594
|
+
// pg-connection-string reads these query parameters before the URL's user info.
|
|
595
|
+
url.searchParams.set('user', credential.user);
|
|
596
|
+
url.searchParams.set('password', credential.password);
|
|
597
|
+
base.connectionString = url.toString();
|
|
598
|
+
}
|
|
599
|
+
if (dialect === 'mysql' && typeof uri === 'string') {
|
|
600
|
+
let url;
|
|
601
|
+
try {
|
|
602
|
+
url = new URL(uri);
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
throw new TypeError('database.uri is not a URL; the restricted Console account cannot reuse it');
|
|
606
|
+
}
|
|
607
|
+
const fromUri = {};
|
|
608
|
+
if (url.hostname)
|
|
609
|
+
fromUri.host = decodeURIComponent(url.hostname);
|
|
610
|
+
if (url.port)
|
|
611
|
+
fromUri.port = Number(url.port);
|
|
612
|
+
if (url.pathname.length > 1)
|
|
613
|
+
fromUri.database = decodeURIComponent(url.pathname.slice(1));
|
|
614
|
+
for (const [key, value] of url.searchParams) {
|
|
615
|
+
if (!MYSQL_URI_KEPT_PARAMS.has(key))
|
|
616
|
+
continue;
|
|
617
|
+
try {
|
|
618
|
+
fromUri[key] = JSON.parse(value);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
fromUri[key] = value;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
Object.assign(base, { ...fromUri, ...rest });
|
|
625
|
+
}
|
|
626
|
+
base.user = credential.user;
|
|
627
|
+
base.password = credential.password;
|
|
628
|
+
base.pool = { max: 2, min: 0 };
|
|
629
|
+
base.resetSession = true;
|
|
630
|
+
if (dialect === 'mysql')
|
|
631
|
+
Object.assign(base, { flags: ['-LOCAL_FILES', '-MULTI_STATEMENTS'], multipleStatements: false });
|
|
632
|
+
return base;
|
|
633
|
+
}
|
package/lib/dialects/mongodb.js
CHANGED
|
@@ -107,6 +107,10 @@ export class MongoDBDialect extends Dialect {
|
|
|
107
107
|
return await this.executeDropCollection(collection);
|
|
108
108
|
case 'createCollection':
|
|
109
109
|
return await this.executeCreateCollection(query.collection);
|
|
110
|
+
case 'listCollections':
|
|
111
|
+
// `collection` is ignored; `filter` filters collection infos (e.g. `{ name: { $in: [...] } }`).
|
|
112
|
+
return await this.db.listCollections(query.filter ?? {}, { nameOnly: true }).toArray()
|
|
113
|
+
.then((infos) => infos.map(info => ({ name: info.name })));
|
|
110
114
|
default:
|
|
111
115
|
throw new Error(`不支持的 MongoDB 操作: ${query.operation}`);
|
|
112
116
|
}
|
|
@@ -387,8 +391,9 @@ export class MongoDBDialect extends Dialect {
|
|
|
387
391
|
if (query.limit) {
|
|
388
392
|
cursor = cursor.limit(query.limit);
|
|
389
393
|
}
|
|
390
|
-
|
|
391
|
-
|
|
394
|
+
// The driver's FindCursor names this `project()` (there is no `projection()` method).
|
|
395
|
+
if (query.projection && Object.keys(query.projection).length > 0) {
|
|
396
|
+
cursor = cursor.project(query.projection);
|
|
392
397
|
}
|
|
393
398
|
return await cursor.toArray();
|
|
394
399
|
}
|