dbx/apps/desktop/src/lib/databaseUserAdmin.ts

376 lines
16 KiB
TypeScript

import type { DatabaseType, QueryResult } from "@/types/database";
import { supportsDatabaseFeature } from "./databaseDriverManifest";
export type UserAdminDialect = "mysql" | "postgres";
export type PrivilegeScope = "mysql" | "database" | "schema" | "table" | "role";
export interface DatabaseUserIdentity {
user: string;
host: string;
plugin?: string;
}
export interface CreatePrincipalInput extends DatabaseUserIdentity {
password: string;
canLogin?: boolean;
}
export interface PrivilegeChangeInput {
user: DatabaseUserIdentity;
privileges: string[];
database: string;
table?: string;
grantOption?: boolean;
scope?: PrivilegeScope;
role?: string;
}
export interface DatabaseUserAdminProvider {
dialect: UserAdminDialect;
defaultScope: PrivilegeScope;
listUsersSql(): string;
fallbackListUsersSql?: () => string;
parseUsers(result: QueryResult): DatabaseUserIdentity[];
parseFallbackUsers?: (result: QueryResult) => DatabaseUserIdentity[];
showGrantsSql(user: DatabaseUserIdentity): string;
createUserSql(input: CreatePrincipalInput): string;
alterPasswordSql(user: DatabaseUserIdentity, password: string): string;
alterLoginSql(user: DatabaseUserIdentity, enabled: boolean): string;
dropUserSql(user: DatabaseUserIdentity): string;
grantPrivilegesSql(input: PrivilegeChangeInput): string;
revokePrivilegesSql(input: PrivilegeChangeInput): string;
label(user: DatabaseUserIdentity): string;
detail(user: DatabaseUserIdentity): string | undefined;
privilegesForScope(scope: PrivilegeScope): readonly string[];
defaultPrivilegesForScope(scope: PrivilegeScope): string[];
}
export const MYSQL_USER_ADMIN_TYPES = new Set<DatabaseType>(["mysql", "goldendb"]);
export const POSTGRES_USER_ADMIN_TYPES = new Set<DatabaseType>(["postgres", "gaussdb", "highgo", "kingbase", "kwdb", "opengauss", "questdb", "vastbase"]);
export const MYSQL_COMMON_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "EXECUTE", "SHOW VIEW", "TRIGGER", "EVENT", "CREATE TEMPORARY TABLES"] as const;
export const POSTGRES_DATABASE_PRIVILEGES = ["CONNECT", "CREATE", "TEMPORARY"] as const;
export const POSTGRES_SCHEMA_PRIVILEGES = ["USAGE", "CREATE"] as const;
export const POSTGRES_TABLE_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER"] as const;
export function supportsDatabaseUserAdmin(dbType: DatabaseType | undefined): boolean {
return !!dbType && supportsDatabaseFeature(dbType, "userAdmin") && !!getDatabaseUserAdminProvider(dbType);
}
export function getDatabaseUserAdminProvider(dbType: DatabaseType | undefined): DatabaseUserAdminProvider | null {
if (!dbType) return null;
if (MYSQL_USER_ADMIN_TYPES.has(dbType)) return mysqlUserAdminProvider;
if (POSTGRES_USER_ADMIN_TYPES.has(dbType)) return postgresUserAdminProvider;
return null;
}
export function quoteSqlString(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
export function quoteMySqlString(value: string): string {
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`;
}
export function quoteMySqlIdentifier(value: string): string {
return `\`${value.replace(/`/g, "``")}\``;
}
export function quotePostgresIdentifier(value: string): string {
return `"${value.replace(/"/g, '""')}"`;
}
export function mysqlUserAccount(user: DatabaseUserIdentity): string {
return `${quoteMySqlString(user.user)}@${quoteMySqlString(user.host)}`;
}
export function mysqlUserLabel(user: DatabaseUserIdentity): string {
return `${user.user}@${user.host}`;
}
export function postgresRoleLabel(user: DatabaseUserIdentity): string {
return user.user;
}
export function mysqlListUsersSql(): string {
return "SELECT User AS user, Host AS host, plugin AS plugin FROM mysql.user ORDER BY User, Host;";
}
export function mysqlListUsersFallbackSql(): string {
return "SELECT DISTINCT GRANTEE AS grantee FROM information_schema.USER_PRIVILEGES ORDER BY GRANTEE;";
}
export function mysqlShowGrantsSql(user: DatabaseUserIdentity): string {
return `SHOW GRANTS FOR ${mysqlUserAccount(user)};`;
}
export function mysqlCreateUserSql(input: CreatePrincipalInput): string {
return `CREATE USER ${mysqlUserAccount(input)} IDENTIFIED BY ${quoteMySqlString(input.password)};`;
}
export function mysqlAlterUserPasswordSql(user: DatabaseUserIdentity, password: string): string {
return `ALTER USER ${mysqlUserAccount(user)} IDENTIFIED BY ${quoteMySqlString(password)};`;
}
export function mysqlAlterUserAccountLockSql(user: DatabaseUserIdentity, locked: boolean): string {
return `ALTER USER ${mysqlUserAccount(user)} ACCOUNT ${locked ? "LOCK" : "UNLOCK"};`;
}
export function mysqlDropUserSql(user: DatabaseUserIdentity): string {
return `DROP USER ${mysqlUserAccount(user)};`;
}
export function mysqlPrivilegeTargetSql(database: string, table = "*"): string {
const db = database.trim() || "*";
const tbl = table.trim() || "*";
const dbSql = db === "*" ? "*" : quoteMySqlIdentifier(db);
const tableSql = tbl === "*" ? "*" : quoteMySqlIdentifier(tbl);
return `${dbSql}.${tableSql}`;
}
export function mysqlGrantPrivilegesSql(input: PrivilegeChangeInput): string {
const privileges = normalizePrivileges(input.privileges).join(", ");
const grantOption = input.grantOption ? " WITH GRANT OPTION" : "";
return `GRANT ${privileges} ON ${mysqlPrivilegeTargetSql(input.database, input.table)} TO ${mysqlUserAccount(input.user)}${grantOption};`;
}
export function mysqlRevokePrivilegesSql(input: PrivilegeChangeInput): string {
const privileges = normalizePrivileges(input.privileges).join(", ");
return `REVOKE ${privileges} ON ${mysqlPrivilegeTargetSql(input.database, input.table)} FROM ${mysqlUserAccount(input.user)};`;
}
export function normalizePrivileges(privileges: string[], fallback = "SELECT"): string[] {
const normalized = privileges.map((privilege) => privilege.trim().toUpperCase()).filter(Boolean);
return Array.from(new Set(normalized.length > 0 ? normalized : [fallback]));
}
export const normalizeMySqlPrivileges = normalizePrivileges;
export function usersFromMySqlUserResult(result: QueryResult): DatabaseUserIdentity[] {
const userIndex = columnIndex(result, "user", "User");
const hostIndex = columnIndex(result, "host", "Host");
const pluginIndex = columnIndex(result, "plugin", "Plugin");
if (userIndex < 0 || hostIndex < 0) return [];
return result.rows
.map((row) => ({
user: String(row[userIndex] ?? ""),
host: String(row[hostIndex] ?? ""),
plugin: pluginIndex >= 0 && row[pluginIndex] != null ? String(row[pluginIndex]) : undefined,
}))
.filter((user) => user.user || user.host);
}
export function usersFromMySqlGranteeResult(result: QueryResult): DatabaseUserIdentity[] {
const granteeIndex = columnIndex(result, "grantee", "GRANTEE");
if (granteeIndex < 0) return [];
return result.rows.flatMap((row) => {
const parsed = parseMySqlGrantee(String(row[granteeIndex] ?? ""));
return parsed ? [parsed] : [];
});
}
export function postgresListRolesSql(): string {
const bypassRls = postgresRoleBypassRlsSql("r");
return `
SELECT
r.rolname AS user,
CASE WHEN r.rolcanlogin THEN 'LOGIN' ELSE 'ROLE' END AS host,
concat_ws(', ',
CASE WHEN r.rolsuper THEN 'SUPERUSER' END,
CASE WHEN r.rolcreatedb THEN 'CREATEDB' END,
CASE WHEN r.rolcreaterole THEN 'CREATEROLE' END,
CASE WHEN r.rolreplication THEN 'REPLICATION' END,
CASE WHEN ${bypassRls} THEN 'BYPASSRLS' END
) AS plugin
FROM pg_catalog.pg_roles r
ORDER BY r.rolname;`.trim();
}
export function usersFromPostgresRolesResult(result: QueryResult): DatabaseUserIdentity[] {
return usersFromMySqlUserResult(result);
}
export function postgresShowGrantsSql(user: DatabaseUserIdentity): string {
const role = quoteSqlString(user.user);
const bypassRls = postgresRoleBypassRlsSql("r");
return `
WITH target AS (
SELECT oid, rolname, rolsuper, rolcreatedb, rolcreaterole, rolcanlogin, rolreplication, ${bypassRls} AS rolbypassrls
FROM pg_catalog.pg_roles r
WHERE r.rolname = ${role}
)
SELECT line
FROM (
SELECT 1 AS sort, 'Role: ' || quote_ident(rolname) AS line
FROM target
UNION ALL
SELECT 2, 'Attributes: ' || COALESCE(NULLIF(concat_ws(', ',
CASE WHEN rolsuper THEN 'SUPERUSER' END,
CASE WHEN rolcreatedb THEN 'CREATEDB' END,
CASE WHEN rolcreaterole THEN 'CREATEROLE' END,
CASE WHEN rolcanlogin THEN 'LOGIN' ELSE 'NOLOGIN' END,
CASE WHEN rolreplication THEN 'REPLICATION' END,
CASE WHEN rolbypassrls THEN 'BYPASSRLS' END
), ''), 'none')
FROM target
UNION ALL
SELECT 10, 'Member of: ' || quote_ident(parent.rolname) || CASE WHEN m.admin_option THEN ' WITH ADMIN OPTION' ELSE '' END
FROM pg_catalog.pg_auth_members m
JOIN target t ON t.oid = m.member
JOIN pg_catalog.pg_roles parent ON parent.oid = m.roleid
UNION ALL
SELECT 20, 'Has member: ' || quote_ident(member.rolname) || CASE WHEN m.admin_option THEN ' WITH ADMIN OPTION' ELSE '' END
FROM pg_catalog.pg_auth_members m
JOIN target t ON t.oid = m.roleid
JOIN pg_catalog.pg_roles member ON member.oid = m.member
UNION ALL
SELECT 30, 'Database: ' || quote_ident(d.datname) || ' = ' ||
concat_ws(', ',
CASE WHEN has_database_privilege(t.rolname, d.oid, 'CONNECT') THEN 'CONNECT' END,
CASE WHEN has_database_privilege(t.rolname, d.oid, 'CREATE') THEN 'CREATE' END,
CASE WHEN has_database_privilege(t.rolname, d.oid, 'TEMPORARY') THEN 'TEMPORARY' END
)
FROM target t
CROSS JOIN pg_catalog.pg_database d
WHERE has_database_privilege(t.rolname, d.oid, 'CONNECT')
OR has_database_privilege(t.rolname, d.oid, 'CREATE')
OR has_database_privilege(t.rolname, d.oid, 'TEMPORARY')
UNION ALL
SELECT 40, 'Schema: ' || quote_ident(n.nspname) || ' = ' ||
concat_ws(', ',
CASE WHEN has_schema_privilege(t.rolname, n.oid, 'USAGE') THEN 'USAGE' END,
CASE WHEN has_schema_privilege(t.rolname, n.oid, 'CREATE') THEN 'CREATE' END
)
FROM target t
CROSS JOIN pg_catalog.pg_namespace n
WHERE n.nspname NOT LIKE 'pg~_%' ESCAPE '~'
AND n.nspname <> 'information_schema'
AND (has_schema_privilege(t.rolname, n.oid, 'USAGE') OR has_schema_privilege(t.rolname, n.oid, 'CREATE'))
UNION ALL
SELECT 50, 'Table: ' || quote_ident(table_schema) || '.' || quote_ident(table_name) || ' = ' ||
string_agg(privilege_type || CASE WHEN is_grantable = 'YES' THEN ' WITH GRANT OPTION' ELSE '' END, ', ' ORDER BY privilege_type)
FROM information_schema.role_table_grants
WHERE grantee = ${role}
GROUP BY table_schema, table_name
) grants
ORDER BY sort, line;`.trim();
}
export function postgresCreateRoleSql(input: CreatePrincipalInput): string {
const login = input.canLogin === false ? "NOLOGIN" : "LOGIN";
return `CREATE ROLE ${quotePostgresIdentifier(input.user)} ${login} PASSWORD ${quoteSqlString(input.password)};`;
}
export function postgresAlterRolePasswordSql(user: DatabaseUserIdentity, password: string): string {
return `ALTER ROLE ${quotePostgresIdentifier(user.user)} PASSWORD ${quoteSqlString(password)};`;
}
export function postgresAlterRoleLoginSql(user: DatabaseUserIdentity, enabled: boolean): string {
return `ALTER ROLE ${quotePostgresIdentifier(user.user)} ${enabled ? "LOGIN" : "NOLOGIN"};`;
}
export function postgresDropRoleSql(user: DatabaseUserIdentity): string {
return `DROP ROLE ${quotePostgresIdentifier(user.user)};`;
}
export function postgresPrivilegeTargetSql(input: Pick<PrivilegeChangeInput, "scope" | "database" | "table">): string {
const scope = input.scope || "database";
const name = input.database.trim();
if (scope === "database") return `DATABASE ${quotePostgresIdentifier(name || "postgres")}`;
if (scope === "schema") return `SCHEMA ${quotePostgresIdentifier(name || "public")}`;
const schema = name || "public";
const table = input.table?.trim() || "*";
if (table === "*") return `ALL TABLES IN SCHEMA ${quotePostgresIdentifier(schema)}`;
return `TABLE ${quotePostgresIdentifier(schema)}.${quotePostgresIdentifier(table)}`;
}
export function postgresGrantPrivilegesSql(input: PrivilegeChangeInput): string {
if (input.scope === "role") {
return `GRANT ${quotePostgresIdentifier(input.role?.trim() || "")} TO ${quotePostgresIdentifier(input.user.user)}${input.grantOption ? " WITH ADMIN OPTION" : ""};`;
}
const privileges = normalizePrivileges(input.privileges, postgresDefaultPrivilege(input.scope)).join(", ");
return `GRANT ${privileges} ON ${postgresPrivilegeTargetSql(input)} TO ${quotePostgresIdentifier(input.user.user)}${input.grantOption ? " WITH GRANT OPTION" : ""};`;
}
export function postgresRevokePrivilegesSql(input: PrivilegeChangeInput): string {
if (input.scope === "role") {
return `REVOKE ${quotePostgresIdentifier(input.role?.trim() || "")} FROM ${quotePostgresIdentifier(input.user.user)};`;
}
const privileges = normalizePrivileges(input.privileges, postgresDefaultPrivilege(input.scope)).join(", ");
return `REVOKE ${privileges} ON ${postgresPrivilegeTargetSql(input)} FROM ${quotePostgresIdentifier(input.user.user)};`;
}
export function grantsFromQueryResult(result: QueryResult): string[] {
return result.rows.map((row) => String(row[0] ?? "")).filter(Boolean);
}
function columnIndex(result: QueryResult, ...names: string[]): number {
const wanted = new Set(names.map((name) => name.toLowerCase()));
return result.columns.findIndex((column) => wanted.has(column.toLowerCase()));
}
function parseMySqlGrantee(value: string): DatabaseUserIdentity | null {
const match = /^'((?:''|[^'])*)'@'((?:''|[^'])*)'$/.exec(value.trim());
if (!match) return null;
return {
user: match[1].replace(/''/g, "'"),
host: match[2].replace(/''/g, "'"),
};
}
function postgresDefaultPrivilege(scope: PrivilegeScope | undefined): string {
if (scope === "schema") return "USAGE";
if (scope === "database") return "CONNECT";
return "SELECT";
}
function postgresPrivilegesForScope(scope: PrivilegeScope): readonly string[] {
if (scope === "database") return POSTGRES_DATABASE_PRIVILEGES;
if (scope === "schema") return POSTGRES_SCHEMA_PRIVILEGES;
if (scope === "table") return POSTGRES_TABLE_PRIVILEGES;
return [];
}
function postgresRoleBypassRlsSql(alias: string): string {
return `row_to_json(${alias})::text LIKE '%"rolbypassrls":true%'`;
}
export const mysqlUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "mysql",
defaultScope: "mysql",
listUsersSql: mysqlListUsersSql,
fallbackListUsersSql: mysqlListUsersFallbackSql,
parseUsers: usersFromMySqlUserResult,
parseFallbackUsers: usersFromMySqlGranteeResult,
showGrantsSql: mysqlShowGrantsSql,
createUserSql: mysqlCreateUserSql,
alterPasswordSql: mysqlAlterUserPasswordSql,
alterLoginSql: (user, enabled) => mysqlAlterUserAccountLockSql(user, !enabled),
dropUserSql: mysqlDropUserSql,
grantPrivilegesSql: mysqlGrantPrivilegesSql,
revokePrivilegesSql: mysqlRevokePrivilegesSql,
label: mysqlUserLabel,
detail: (user) => user.plugin,
privilegesForScope: () => MYSQL_COMMON_PRIVILEGES,
defaultPrivilegesForScope: () => ["SELECT"],
};
export const postgresUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "postgres",
defaultScope: "database",
listUsersSql: postgresListRolesSql,
parseUsers: usersFromPostgresRolesResult,
showGrantsSql: postgresShowGrantsSql,
createUserSql: postgresCreateRoleSql,
alterPasswordSql: postgresAlterRolePasswordSql,
alterLoginSql: postgresAlterRoleLoginSql,
dropUserSql: postgresDropRoleSql,
grantPrivilegesSql: postgresGrantPrivilegesSql,
revokePrivilegesSql: postgresRevokePrivilegesSql,
label: postgresRoleLabel,
detail: (user) => [user.host, user.plugin].filter(Boolean).join(" · ") || undefined,
privilegesForScope: postgresPrivilegesForScope,
defaultPrivilegesForScope: (scope) => (scope === "role" ? [] : [postgresDefaultPrivilege(scope)]),
};