fix(doris): restore user privilege management

This commit is contained in:
t8y2 committed 2026-07-21 19:46:03 +08:00
1 parent c2de49c155
commit 9421a1156e
4 files changed
+162 -2

No files matched your search

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getDatabaseUserAdminProvider, kingbaseShowGrantsSql, mysqlPrivilegeSelectionFromGrants } from "@/lib/database/databaseUserAdmin"; import { dorisGrantPrivilegesSql, dorisGrantsResult, dorisPrivilegeTargetSql, dorisUsersResult, getDatabaseUserAdminProvider, kingbaseShowGrantsSql, mysqlPrivilegeSelectionFromGrants } from "@/lib/database/databaseUserAdmin";
import type { QueryResult } from "@/types/database";
function result(columns: string[], rows: QueryResult["rows"]): QueryResult {
return { columns, rows, affected_rows: 0, execution_time_ms: 1 };
}
describe("MySQL grant privilege selection", () => { describe("MySQL grant privilege selection", () => {
const availablePrivileges = ["SELECT", "INSERT", "UPDATE", "EXECUTE"]; const availablePrivileges = ["SELECT", "INSERT", "UPDATE", "EXECUTE"];
@@ -50,8 +55,28 @@ describe("MySQL grant privilege selection", () => {
}); });
describe("database user admin providers", () => { describe("database user admin providers", () => {
it("uses Doris 2.x user and privilege syntax", () => {
const provider = getDatabaseUserAdminProvider("doris");
expect(provider?.listUsersSql()).toBe("SHOW ALL GRANTS;");
expect(provider?.showGrantsSql({ user: "reporter", host: "%" })).toBe("SHOW GRANTS FOR 'reporter'@'%';");
expect(provider?.alterPasswordSql?.({ user: "reporter", host: "%" }, "new'secret")).toBe("SET PASSWORD FOR 'reporter'@'%' = PASSWORD('new''secret');");
expect(dorisPrivilegeTargetSql("analytics", "daily`rollup")).toBe("`internal`.`analytics`.`daily``rollup`");
expect(dorisGrantPrivilegesSql({ user: { user: "reporter", host: "%" }, privileges: ["select_priv", "LOAD_PRIV"], database: "analytics", grantOption: true })).toBe("GRANT SELECT_PRIV, LOAD_PRIV ON `internal`.`analytics`.* TO 'reporter'@'%';");
expect(provider?.alterLoginSql).toBeUndefined();
expect(provider?.privilegesForScope?.("table")).toEqual(["SELECT_PRIV", "LOAD_PRIV", "ALTER_PRIV", "CREATE_PRIV", "DROP_PRIV", "SHOW_VIEW_PRIV"]);
});
it("parses Doris SHOW GRANTS users and structured privileges", () => {
const grants = result(["UserIdentity", "Comment", "Password", "Roles", "GlobalPrivs", "DatabasePrivs", "TablePrivs"], [["'root'@'%'", "ROOT", "No", "operator", "Admin_priv", "internal.analytics: Select_priv", null]]);
expect(dorisUsersResult(grants)).toEqual([{ user: "root", host: "%" }]);
expect(dorisGrantsResult(grants)).toEqual(["Roles: operator", "GlobalPrivs: Admin_priv", "DatabasePrivs: internal.analytics: Select_priv"]);
});
it("syncs loaded grants only for the MySQL provider", () => { it("syncs loaded grants only for the MySQL provider", () => {
const mysqlProvider = getDatabaseUserAdminProvider("mysql"); const mysqlProvider = getDatabaseUserAdminProvider("mysql");
const dorisProvider = getDatabaseUserAdminProvider("doris");
const postgresProvider = getDatabaseUserAdminProvider("postgres"); const postgresProvider = getDatabaseUserAdminProvider("postgres");
const starrocksProvider = getDatabaseUserAdminProvider("starrocks"); const starrocksProvider = getDatabaseUserAdminProvider("starrocks");
@@ -64,9 +89,11 @@ describe("database user admin providers", () => {
}), }),
).toEqual({ privileges: ["INSERT"], grantOption: false }); ).toEqual({ privileges: ["INSERT"], grantOption: false });
expect(postgresProvider?.privilegeSelectionFromGrants).toBeUndefined(); expect(postgresProvider?.privilegeSelectionFromGrants).toBeUndefined();
expect(dorisProvider?.privilegeSelectionFromGrants).toBeUndefined();
expect(starrocksProvider?.privilegeSelectionFromGrants).toBeUndefined(); expect(starrocksProvider?.privilegeSelectionFromGrants).toBeUndefined();
expect(mysqlProvider?.defaultPrivilegesForScope?.("mysql")).toEqual(["SELECT"]); expect(mysqlProvider?.defaultPrivilegesForScope?.("mysql")).toEqual(["SELECT"]);
expect(postgresProvider?.defaultPrivilegesForScope?.("database")).toEqual(["CONNECT"]); expect(postgresProvider?.defaultPrivilegesForScope?.("database")).toEqual(["CONNECT"]);
expect(dorisProvider?.defaultPrivilegesForScope?.("table")).toEqual(["SELECT_PRIV"]);
expect(starrocksProvider?.defaultPrivilegesForScope?.("table")).toEqual(["SELECT"]); expect(starrocksProvider?.defaultPrivilegesForScope?.("table")).toEqual(["SELECT"]);
}); });
@@ -65,6 +65,7 @@ export const KINGBASE_USER_ADMIN_TYPES = new Set<DatabaseType>(["kingbase"]);
export const POSTGRES_USER_ADMIN_TYPES = new Set<DatabaseType>(["postgres", "gaussdb", "highgo", "kwdb", "opengauss", "questdb", "vastbase"]); export const POSTGRES_USER_ADMIN_TYPES = new Set<DatabaseType>(["postgres", "gaussdb", "highgo", "kwdb", "opengauss", "questdb", "vastbase"]);
export const MYSQL_COMMON_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "EXECUTE", "SHOW VIEW", "CREATE VIEW", "CREATE ROUTINE", "ALTER ROUTINE", "TRIGGER", "EVENT", "CREATE TEMPORARY TABLES", "LOCK TABLES"] as const; export const MYSQL_COMMON_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "EXECUTE", "SHOW VIEW", "CREATE VIEW", "CREATE ROUTINE", "ALTER ROUTINE", "TRIGGER", "EVENT", "CREATE TEMPORARY TABLES", "LOCK TABLES"] as const;
export const DORIS_TABLE_PRIVILEGES = ["SELECT_PRIV", "LOAD_PRIV", "ALTER_PRIV", "CREATE_PRIV", "DROP_PRIV", "SHOW_VIEW_PRIV"] as const;
export const STARROCKS_TABLE_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "EXPORT", "ALL"] as const; export const STARROCKS_TABLE_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "EXPORT", "ALL"] as const;
export const POSTGRES_DATABASE_PRIVILEGES = ["CONNECT", "CREATE", "TEMPORARY"] as const; export const POSTGRES_DATABASE_PRIVILEGES = ["CONNECT", "CREATE", "TEMPORARY"] as const;
@@ -111,6 +112,30 @@ export function starrocksListUsersSql(): string {
return "SHOW USERS;"; return "SHOW USERS;";
} }
export function dorisListUsersSql(): string {
return "SHOW ALL GRANTS;";
}
export function dorisUsersResult(result: QueryResult): DatabaseUserIdentity[] {
const userIndex = columnIndex(result, "UserIdentity");
if (userIndex < 0) return [];
return result.rows.flatMap((row) => {
const parsed = parseMySqlGrantee(String(row[userIndex] ?? ""));
return parsed ? [parsed] : [];
});
}
export function dorisGrantsResult(result: QueryResult): string[] {
const ignoredColumns = new Set(["useridentity", "comment", "password"]);
return result.rows.flatMap((row) =>
result.columns.flatMap((column, index) => {
if (ignoredColumns.has(column.toLowerCase())) return [];
const value = String(row[index] ?? "").trim();
return value && value.toUpperCase() !== "NULL" ? [`${column}: ${value}`] : [];
}),
);
}
export function starrocksUsersResult(result: QueryResult): DatabaseUserIdentity[] { export function starrocksUsersResult(result: QueryResult): DatabaseUserIdentity[] {
const userIndex = columnIndex(result, "user", "User"); const userIndex = columnIndex(result, "user", "User");
if (userIndex < 0) return []; if (userIndex < 0) return [];
@@ -138,6 +163,10 @@ export function mysqlAlterUserPasswordSql(user: DatabaseUserIdentity, password:
return `ALTER USER ${mysqlUserAccount(user)} IDENTIFIED BY ${quoteMySqlString(password)};`; return `ALTER USER ${mysqlUserAccount(user)} IDENTIFIED BY ${quoteMySqlString(password)};`;
} }
export function dorisAlterUserPasswordSql(user: DatabaseUserIdentity, password: string): string {
return `SET PASSWORD FOR ${mysqlUserAccount(user)} = PASSWORD(${quoteMySqlString(password)});`;
}
export function mysqlAlterUserAccountLockSql(user: DatabaseUserIdentity, locked: boolean): string { export function mysqlAlterUserAccountLockSql(user: DatabaseUserIdentity, locked: boolean): string {
return `ALTER USER ${mysqlUserAccount(user)} ACCOUNT ${locked ? "LOCK" : "UNLOCK"};`; return `ALTER USER ${mysqlUserAccount(user)} ACCOUNT ${locked ? "LOCK" : "UNLOCK"};`;
} }
@@ -186,6 +215,25 @@ export function starrocksRevokePrivilegesSql(input: PrivilegeChangeInput): strin
return `REVOKE ${privileges} ON ${starrocksPrivilegeTargetSql(input.database, input.table)} FROM USER ${mysqlUserAccount(input.user)};`; return `REVOKE ${privileges} ON ${starrocksPrivilegeTargetSql(input.database, input.table)} FROM USER ${mysqlUserAccount(input.user)};`;
} }
export function dorisPrivilegeTargetSql(database: string, table = "*"): string {
const db = database.trim() || "*";
const tbl = table.trim() || "*";
if (db === "*") return "*.*.*";
const tableSql = tbl === "*" ? "*" : quoteMySqlIdentifier(tbl);
return `${quoteMySqlIdentifier("internal")}.${quoteMySqlIdentifier(db)}.${tableSql}`;
}
export function dorisGrantPrivilegesSql(input: PrivilegeChangeInput): string {
const privileges = normalizePrivileges(input.privileges, "SELECT_PRIV").join(", ");
// Doris 2.x GRANT has no WITH GRANT OPTION clause; GRANT_PRIV is managed as an explicit privilege instead.
return `GRANT ${privileges} ON ${dorisPrivilegeTargetSql(input.database, input.table)} TO ${mysqlUserAccount(input.user)};`;
}
export function dorisRevokePrivilegesSql(input: PrivilegeChangeInput): string {
const privileges = normalizePrivileges(input.privileges, "SELECT_PRIV").join(", ");
return `REVOKE ${privileges} ON ${dorisPrivilegeTargetSql(input.database, input.table)} FROM ${mysqlUserAccount(input.user)};`;
}
export function normalizePrivileges(privileges: string[], fallback = "SELECT"): string[] { export function normalizePrivileges(privileges: string[], fallback = "SELECT"): string[] {
const normalized = privileges.map((privilege) => privilege.trim().toUpperCase()).filter(Boolean); const normalized = privileges.map((privilege) => privilege.trim().toUpperCase()).filter(Boolean);
return Array.from(new Set(normalized.length > 0 ? normalized : [fallback])); return Array.from(new Set(normalized.length > 0 ? normalized : [fallback]));
@@ -582,6 +630,24 @@ export const kingbaseUserAdminProvider: DatabaseUserAdminProvider = {
showGrantsSql: kingbaseShowGrantsSql, showGrantsSql: kingbaseShowGrantsSql,
}; };
export const dorisUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "mysql",
defaultScope: "table",
listUsersSql: dorisListUsersSql,
parseUsers: dorisUsersResult,
showGrantsSql: mysqlShowGrantsSql,
parseGrants: dorisGrantsResult,
createUserSql: mysqlCreateUserSql,
alterPasswordSql: dorisAlterUserPasswordSql,
dropUserSql: mysqlDropUserSql,
grantPrivilegesSql: dorisGrantPrivilegesSql,
revokePrivilegesSql: dorisRevokePrivilegesSql,
label: mysqlUserLabel,
detail: (user) => user.plugin,
privilegesForScope: () => DORIS_TABLE_PRIVILEGES,
defaultPrivilegesForScope: () => ["SELECT_PRIV"],
};
export const starrocksUserAdminProvider: DatabaseUserAdminProvider = { export const starrocksUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "mysql", dialect: "mysql",
defaultScope: "table", defaultScope: "table",
@@ -603,6 +669,7 @@ export const starrocksUserAdminProvider: DatabaseUserAdminProvider = {
const DATABASE_USER_ADMIN_PROVIDER_BY_TYPE = new Map<DatabaseType, DatabaseUserAdminProvider>([ const DATABASE_USER_ADMIN_PROVIDER_BY_TYPE = new Map<DatabaseType, DatabaseUserAdminProvider>([
["mysql", mysqlUserAdminProvider], ["mysql", mysqlUserAdminProvider],
["goldendb", mysqlUserAdminProvider], ["goldendb", mysqlUserAdminProvider],
["doris", dorisUserAdminProvider],
["kingbase", kingbaseUserAdminProvider], ["kingbase", kingbaseUserAdminProvider],
["postgres", postgresUserAdminProvider], ["postgres", postgresUserAdminProvider],
["gaussdb", postgresUserAdminProvider], ["gaussdb", postgresUserAdminProvider],
@@ -496,7 +496,7 @@
"databaseCreate": true, "databaseCreate": true,
"fieldLineage": false, "fieldLineage": false,
"sqlExplain": false, "sqlExplain": false,
"userAdmin": false, "userAdmin": true,
"driverManagement": false "driverManagement": false
} }
}, },
+66
View File
@@ -2,6 +2,13 @@ import { strict as assert } from "node:assert";
import { test } from "vitest"; import { test } from "vitest";
import { import {
connectionSupportsDatabaseUserAdmin, connectionSupportsDatabaseUserAdmin,
dorisAlterUserPasswordSql,
dorisGrantPrivilegesSql,
dorisGrantsResult,
dorisListUsersSql,
dorisPrivilegeTargetSql,
dorisRevokePrivilegesSql,
dorisUsersResult,
grantsFromQueryResult, grantsFromQueryResult,
getDatabaseUserAdminProvider, getDatabaseUserAdminProvider,
mysqlAlterUserAccountLockSql, mysqlAlterUserAccountLockSql,
@@ -253,6 +260,47 @@ test("routes StarRocks to the StarRocks user admin provider", () => {
assert.deepEqual(provider?.privilegesForScope?.("table"), ["SELECT", "INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "EXPORT", "ALL"]); assert.deepEqual(provider?.privilegesForScope?.("table"), ["SELECT", "INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "EXPORT", "ALL"]);
}); });
test("routes Doris to a Doris 2.x user admin provider", () => {
const provider = getDatabaseUserAdminProvider("doris");
assert.ok(provider, "expected a provider for doris");
assert.equal(provider?.dialect, "mysql");
assert.equal(provider?.listUsersSql(), "SHOW ALL GRANTS;");
assert.equal(provider?.showGrantsSql({ user: "root", host: "%" }), "SHOW GRANTS FOR 'root'@'%';");
assert.equal(provider?.createUserSql?.({ user: "app", host: "%", password: "secret" }), "CREATE USER 'app'@'%' IDENTIFIED BY 'secret';");
assert.equal(dorisAlterUserPasswordSql({ user: "app", host: "%" }, "new'secret"), "SET PASSWORD FOR 'app'@'%' = PASSWORD('new''secret');");
assert.equal(provider?.dropUserSql?.({ user: "app", host: "%" }), "DROP USER 'app'@'%';");
assert.equal(dorisPrivilegeTargetSql("*"), "*.*.*");
assert.equal(dorisPrivilegeTargetSql("analytics"), "`internal`.`analytics`.*");
assert.equal(dorisPrivilegeTargetSql("analytics", "daily`rollup"), "`internal`.`analytics`.`daily``rollup`");
const privilegeInput = {
user: { user: "reporter", host: "%" },
privileges: ["select_priv", "LOAD_PRIV"],
database: "analytics",
grantOption: true,
};
assert.equal(dorisGrantPrivilegesSql(privilegeInput), "GRANT SELECT_PRIV, LOAD_PRIV ON `internal`.`analytics`.* TO 'reporter'@'%';");
assert.equal(dorisRevokePrivilegesSql(privilegeInput), "REVOKE SELECT_PRIV, LOAD_PRIV ON `internal`.`analytics`.* FROM 'reporter'@'%';");
assert.equal(provider?.alterLoginSql, undefined);
assert.deepEqual(provider?.privilegesForScope?.("table"), ["SELECT_PRIV", "LOAD_PRIV", "ALTER_PRIV", "CREATE_PRIV", "DROP_PRIV", "SHOW_VIEW_PRIV"]);
});
test("parses Doris SHOW ALL GRANTS results", () => {
const grants = result(
["UserIdentity", "Comment", "Password", "Roles", "GlobalPrivs", "CatalogPrivs", "DatabasePrivs", "TablePrivs"],
[
["'root'@'%'", "ROOT", "No", "operator", "Admin_priv", null, "internal.mysql: Select_priv", null],
["'o''brien'@'localhost'", "", "Yes", "", null, "internal: Select_priv", null, "internal.analytics.orders: Select_priv"],
],
);
assert.equal(dorisListUsersSql(), "SHOW ALL GRANTS;");
assert.deepEqual(dorisUsersResult(grants), [
{ user: "root", host: "%" },
{ user: "o'brien", host: "localhost" },
]);
assert.deepEqual(dorisGrantsResult(grants), ["Roles: operator", "GlobalPrivs: Admin_priv", "DatabasePrivs: internal.mysql: Select_priv", "CatalogPrivs: internal: Select_priv", "TablePrivs: internal.analytics.orders: Select_priv"]);
});
test("resolves user admin support from the effective connection type", () => { test("resolves user admin support from the effective connection type", () => {
const mysqlProtocolStarRocks = { const mysqlProtocolStarRocks = {
id: "starrocks-1", id: "starrocks-1",
@@ -265,9 +313,27 @@ test("resolves user admin support from the effective connection type", () => {
connection_string: "jdbc:mysql://localhost:9030/analytics", connection_string: "jdbc:mysql://localhost:9030/analytics",
driver_profile: "starrocks", driver_profile: "starrocks",
} as ConnectionConfig; } as ConnectionConfig;
const mysqlProtocolDoris = {
id: "doris-1",
db_type: "mysql",
driver_profile: "doris",
} as ConnectionConfig;
const jdbcDoris = {
id: "doris-jdbc-1",
db_type: "jdbc",
connection_string: "jdbc:mysql://localhost:9030/analytics",
driver_profile: "doris",
} as ConnectionConfig;
const mysql = { id: "mysql-1", db_type: "mysql" } as ConnectionConfig;
const goldenDb = { id: "goldendb-1", db_type: "goldendb" } as ConnectionConfig;
assert.equal(resolveDatabaseUserAdminProviderForConnection(mysqlProtocolStarRocks), getDatabaseUserAdminProvider("starrocks")); assert.equal(resolveDatabaseUserAdminProviderForConnection(mysqlProtocolStarRocks), getDatabaseUserAdminProvider("starrocks"));
assert.equal(resolveDatabaseUserAdminProviderForConnection(jdbcStarRocks), getDatabaseUserAdminProvider("starrocks")); assert.equal(resolveDatabaseUserAdminProviderForConnection(jdbcStarRocks), getDatabaseUserAdminProvider("starrocks"));
assert.equal(resolveDatabaseUserAdminProviderForConnection(mysqlProtocolDoris), getDatabaseUserAdminProvider("doris"));
assert.equal(resolveDatabaseUserAdminProviderForConnection(jdbcDoris), getDatabaseUserAdminProvider("doris"));
assert.equal(resolveDatabaseUserAdminProviderForConnection(mysql), getDatabaseUserAdminProvider("mysql"));
assert.equal(resolveDatabaseUserAdminProviderForConnection(goldenDb), getDatabaseUserAdminProvider("goldendb"));
assert.equal(connectionSupportsDatabaseUserAdmin(mysqlProtocolStarRocks), true); assert.equal(connectionSupportsDatabaseUserAdmin(mysqlProtocolStarRocks), true);
assert.equal(connectionSupportsDatabaseUserAdmin(mysqlProtocolDoris), true);
assert.equal(connectionSupportsDatabaseUserAdmin({ id: "sqlite-1", db_type: "sqlite" } as ConnectionConfig), false); assert.equal(connectionSupportsDatabaseUserAdmin({ id: "sqlite-1", db_type: "sqlite" } as ConnectionConfig), false);
}); });