feat(starrocks): enable user and privilege management

This commit is contained in:
jischeng authored and GitHub committed 2026-07-16 18:21:24 +08:00
1 parent 640e82cdc6
commit 41e0989f96
7 files changed
+248 -60

No files matched your search

@@ -14,7 +14,7 @@ import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import type { ConnectionConfig } from "@/types/database"; import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api"; import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard"; import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { grantsFromQueryResult, getDatabaseUserAdminProvider, supportsDatabaseUserAdmin, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin"; import { grantsFromQueryResult, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
const props = defineProps<{ const props = defineProps<{
connection: ConnectionConfig; connection: ConnectionConfig;
@@ -54,9 +54,16 @@ const grantOption = ref(false);
const selectedPrivileges = ref<string[]>(["SELECT"]); const selectedPrivileges = ref<string[]>(["SELECT"]);
const createCanLogin = ref(true); const createCanLogin = ref(true);
const supported = computed(() => supportsDatabaseUserAdmin(props.connection.db_type)); const provider = computed(() => resolveDatabaseUserAdminProviderForConnection(props.connection));
const provider = computed(() => getDatabaseUserAdminProvider(props.connection.db_type)); const supported = computed(() => provider.value !== null);
const isPostgres = computed(() => provider.value?.dialect === "postgres"); const isPostgres = computed(() => provider.value?.dialect === "postgres");
const canCreateUser = computed(() => !!provider.value?.createUserSql);
const canAlterPassword = computed(() => !!provider.value?.alterPasswordSql);
const canAlterLogin = computed(() => !!provider.value?.alterLoginSql);
const canDropUser = computed(() => !!provider.value?.dropUserSql);
const canGrantPrivileges = computed(() => !!provider.value?.grantPrivilegesSql);
const canRevokePrivileges = computed(() => !!provider.value?.revokePrivilegesSql);
const canEditPrivileges = computed(() => canGrantPrivileges.value || canRevokePrivileges.value);
const selectedUser = computed(() => users.value.find((user) => userKey(user) === selectedUserKey.value)); const selectedUser = computed(() => users.value.find((user) => userKey(user) === selectedUserKey.value));
const filteredUsers = computed(() => { const filteredUsers = computed(() => {
const query = search.value.trim().toLowerCase(); const query = search.value.trim().toLowerCase();
@@ -64,7 +71,7 @@ const filteredUsers = computed(() => {
return users.value.filter((user) => userLabel(user).toLowerCase().includes(query)); return users.value.filter((user) => userLabel(user).toLowerCase().includes(query));
}); });
const selectedPrivilegeSet = computed(() => new Set(selectedPrivileges.value)); const selectedPrivilegeSet = computed(() => new Set(selectedPrivileges.value));
const availablePrivileges = computed(() => provider.value?.privilegesForScope(privilegeScope.value) ?? []); const availablePrivileges = computed(() => provider.value?.privilegesForScope?.(privilegeScope.value) ?? []);
const hasPrivilegePicker = computed(() => privilegeScope.value !== "role"); const hasPrivilegePicker = computed(() => privilegeScope.value !== "role");
const loginDisableLabel = computed(() => (isPostgres.value ? t("userAdmin.disableLogin") : t("userAdmin.lock"))); const loginDisableLabel = computed(() => (isPostgres.value ? t("userAdmin.disableLogin") : t("userAdmin.lock")));
const loginEnableLabel = computed(() => (isPostgres.value ? t("userAdmin.enableLogin") : t("userAdmin.unlock"))); const loginEnableLabel = computed(() => (isPostgres.value ? t("userAdmin.enableLogin") : t("userAdmin.unlock")));
@@ -135,7 +142,7 @@ async function loadGrants() {
const result = await api.executeQuery(props.connection.id, "", userProvider.showGrantsSql(user), undefined, undefined, { const result = await api.executeQuery(props.connection.id, "", userProvider.showGrantsSql(user), undefined, undefined, {
maxRows: 1000, maxRows: 1000,
}); });
grants.value = grantsFromQueryResult(result); grants.value = (userProvider.parseGrants ?? grantsFromQueryResult)(result);
} catch (error: any) { } catch (error: any) {
grantError.value = error?.message || String(error); grantError.value = error?.message || String(error);
grants.value = []; grants.value = [];
@@ -188,10 +195,11 @@ async function applyPendingSql() {
function previewCreateUser() { function previewCreateUser() {
const userProvider = provider.value; const userProvider = provider.value;
if (!userProvider) return; const createUserSql = userProvider?.createUserSql;
if (!createUserSql) return;
if (!createUser.value.trim() || !createPassword.value) return; if (!createUser.value.trim() || !createPassword.value) return;
previewSql( previewSql(
userProvider.createUserSql({ createUserSql({
user: createUser.value.trim(), user: createUser.value.trim(),
host: createHost.value.trim() || "%", host: createHost.value.trim() || "%",
password: createPassword.value, password: createPassword.value,
@@ -209,8 +217,9 @@ function previewCreateUser() {
function previewPasswordChange() { function previewPasswordChange() {
const user = selectedUser.value; const user = selectedUser.value;
const userProvider = provider.value; const userProvider = provider.value;
if (!user || !userProvider || !newPassword.value) return; const alterPasswordSql = userProvider?.alterPasswordSql;
previewSql(userProvider.alterPasswordSql(user, newPassword.value), { if (!user || !alterPasswordSql || !newPassword.value) return;
previewSql(alterPasswordSql(user, newPassword.value), {
danger: true, danger: true,
afterApply: async () => { afterApply: async () => {
passwordDialogOpen.value = false; passwordDialogOpen.value = false;
@@ -222,23 +231,26 @@ function previewPasswordChange() {
function previewDropUser() { function previewDropUser() {
const user = selectedUser.value; const user = selectedUser.value;
const userProvider = provider.value; const userProvider = provider.value;
if (!user || !userProvider) return; const dropUserSql = userProvider?.dropUserSql;
previewSql(userProvider.dropUserSql(user), { danger: true }); if (!user || !dropUserSql) return;
previewSql(dropUserSql(user), { danger: true });
} }
function previewLoginChange(enabled: boolean) { function previewLoginChange(enabled: boolean) {
const user = selectedUser.value; const user = selectedUser.value;
const userProvider = provider.value; const userProvider = provider.value;
if (!user || !userProvider) return; const alterLoginSql = userProvider?.alterLoginSql;
previewSql(userProvider.alterLoginSql(user, enabled), { danger: true }); if (!user || !alterLoginSql) return;
previewSql(alterLoginSql(user, enabled), { danger: true });
} }
function previewGrant() { function previewGrant() {
const user = selectedUser.value; const user = selectedUser.value;
const userProvider = provider.value; const userProvider = provider.value;
if (!user || !userProvider || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return; const grantPrivilegesSql = userProvider?.grantPrivilegesSql;
if (!user || !grantPrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
previewSql( previewSql(
userProvider.grantPrivilegesSql({ grantPrivilegesSql({
user, user,
privileges: selectedPrivileges.value, privileges: selectedPrivileges.value,
database: privilegeDatabase.value, database: privilegeDatabase.value,
@@ -253,9 +265,10 @@ function previewGrant() {
function previewRevoke() { function previewRevoke() {
const user = selectedUser.value; const user = selectedUser.value;
const userProvider = provider.value; const userProvider = provider.value;
if (!user || !userProvider || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return; const revokePrivilegesSql = userProvider?.revokePrivilegesSql;
if (!user || !revokePrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
previewSql( previewSql(
userProvider.revokePrivilegesSql({ revokePrivilegesSql({
user, user,
privileges: selectedPrivileges.value, privileges: selectedPrivileges.value,
database: privilegeDatabase.value, database: privilegeDatabase.value,
@@ -270,7 +283,7 @@ function previewRevoke() {
function resetPrivilegeDefaults(scope: PrivilegeScope) { function resetPrivilegeDefaults(scope: PrivilegeScope) {
const userProvider = provider.value; const userProvider = provider.value;
if (!userProvider) return; if (!userProvider) return;
selectedPrivileges.value = userProvider.defaultPrivilegesForScope(scope); selectedPrivileges.value = userProvider.defaultPrivilegesForScope?.(scope) ?? [];
if (userProvider.dialect === "postgres") { if (userProvider.dialect === "postgres") {
if (scope === "database") privilegeDatabase.value = props.connection.database || "postgres"; if (scope === "database") privilegeDatabase.value = props.connection.database || "postgres";
if (scope === "schema" || scope === "table") privilegeDatabase.value = "public"; if (scope === "schema" || scope === "table") privilegeDatabase.value = "public";
@@ -296,7 +309,7 @@ watch(
); );
watch( watch(
() => provider.value?.dialect, () => provider.value,
() => { () => {
privilegeScope.value = provider.value?.defaultScope ?? "mysql"; privilegeScope.value = provider.value?.defaultScope ?? "mysql";
resetPrivilegeDefaults(privilegeScope.value); resetPrivilegeDefaults(privilegeScope.value);
@@ -326,7 +339,7 @@ onMounted(loadUsers);
<RefreshCcw v-else class="h-3.5 w-3.5" /> <RefreshCcw v-else class="h-3.5 w-3.5" />
{{ t("grid.refresh") }} {{ t("grid.refresh") }}
</Button> </Button>
<Button size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="!supported" @click="createDialogOpen = true"> <Button v-if="canCreateUser" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="createDialogOpen = true">
<Plus class="h-3.5 w-3.5" /> <Plus class="h-3.5 w-3.5" />
{{ t("userAdmin.newUser") }} {{ t("userAdmin.newUser") }}
</Button> </Button>
@@ -385,27 +398,27 @@ onMounted(loadUsers);
</Badge> </Badge>
</div> </div>
<div class="ml-auto flex items-center gap-1.5"> <div class="ml-auto flex items-center gap-1.5">
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="passwordDialogOpen = true"> <Button v-if="canAlterPassword" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="passwordDialogOpen = true">
<KeyRound class="h-3.5 w-3.5" /> <KeyRound class="h-3.5 w-3.5" />
{{ t("userAdmin.changePassword") }} {{ t("userAdmin.changePassword") }}
</Button> </Button>
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="previewLoginChange(false)"> <Button v-if="canAlterLogin" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="previewLoginChange(false)">
<Lock class="h-3.5 w-3.5" /> <Lock class="h-3.5 w-3.5" />
{{ loginDisableLabel }} {{ loginDisableLabel }}
</Button> </Button>
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="previewLoginChange(true)"> <Button v-if="canAlterLogin" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="previewLoginChange(true)">
<Unlock class="h-3.5 w-3.5" /> <Unlock class="h-3.5 w-3.5" />
{{ loginEnableLabel }} {{ loginEnableLabel }}
</Button> </Button>
<Button variant="destructive" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="previewDropUser"> <Button v-if="canDropUser" variant="destructive" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="previewDropUser">
<Trash2 class="h-3.5 w-3.5" /> <Trash2 class="h-3.5 w-3.5" />
{{ t("userAdmin.dropUser") }} {{ t("userAdmin.dropUser") }}
</Button> </Button>
</div> </div>
</div> </div>
<div v-if="selectedUser" class="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_320px]"> <div v-if="selectedUser" class="grid min-h-0 flex-1" :class="canEditPrivileges ? 'grid-cols-[minmax(0,1fr)_320px]' : 'grid-cols-1'">
<section class="flex min-h-0 flex-col border-r"> <section class="flex min-h-0 flex-col" :class="{ 'border-r': canEditPrivileges }">
<div class="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/20 px-3 text-xs font-medium"> <div class="flex h-9 shrink-0 items-center gap-2 border-b bg-muted/20 px-3 text-xs font-medium">
<ShieldCheck class="h-3.5 w-3.5" /> <ShieldCheck class="h-3.5 w-3.5" />
{{ t("userAdmin.grants") }} {{ t("userAdmin.grants") }}
@@ -420,7 +433,7 @@ onMounted(loadUsers);
</div> </div>
</section> </section>
<aside class="flex min-h-0 flex-col bg-muted/10"> <aside v-if="canEditPrivileges" class="flex min-h-0 flex-col bg-muted/10">
<div class="border-b p-3"> <div class="border-b p-3">
<div class="text-xs font-semibold">{{ t("userAdmin.privilegeEditor") }}</div> <div class="text-xs font-semibold">{{ t("userAdmin.privilegeEditor") }}</div>
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t("userAdmin.privilegeHint") }}</div> <div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t("userAdmin.privilegeHint") }}</div>
@@ -478,10 +491,10 @@ onMounted(loadUsers);
</label> </label>
</div> </div>
<div class="flex shrink-0 items-center justify-end gap-2 border-t p-3"> <div class="flex shrink-0 items-center justify-end gap-2 border-t p-3">
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="previewRevoke"> <Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="previewRevoke">
{{ t("userAdmin.revoke") }} {{ t("userAdmin.revoke") }}
</Button> </Button>
<Button size="sm" class="h-7 px-2 text-xs" @click="previewGrant"> <Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" @click="previewGrant">
{{ t("userAdmin.grant") }} {{ t("userAdmin.grant") }}
</Button> </Button>
</div> </div>
@@ -147,7 +147,7 @@ import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip"; import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip";
import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection"; import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection"; import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionDeleteTargets, selectedConnectionDuplicateTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin"; import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
import { connectionSupportsProcessList } from "@/lib/database/processListDrivers"; import { connectionSupportsProcessList } from "@/lib/database/processListDrivers";
import { connectionSupportsServerDashboard } from "@/lib/database/mysqlServerStatus"; import { connectionSupportsServerDashboard } from "@/lib/database/mysqlServerStatus";
import { connectionSupportsServerDashboard as connectionSupportsPgServerDashboard } from "@/lib/database/postgresServerStatus"; import { connectionSupportsServerDashboard as connectionSupportsPgServerDashboard } from "@/lib/database/postgresServerStatus";
@@ -5128,7 +5128,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
} }
const sqlHistoryMenu = savedSqlHistorySubmenu(); const sqlHistoryMenu = savedSqlHistorySubmenu();
if (sqlHistoryMenu) items.push(sqlHistoryMenu); if (sqlHistoryMenu) items.push(sqlHistoryMenu);
if (supportsDatabaseUserAdmin(currentDatabaseType())) { if (node.connectionId && connectionSupportsDatabaseUserAdmin(connectionStore.getConfig(node.connectionId))) {
items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound }); items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound });
} }
if (node.connectionId && connectionSupportsProcessList(connectionStore.getConfig(node.connectionId))) { if (node.connectionId && connectionSupportsProcessList(connectionStore.getConfig(node.connectionId))) {
@@ -1,5 +1,6 @@
import type { DatabaseType, QueryResult } from "@/types/database"; import type { ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest"; import { supportsDatabaseFeature } from "@/lib/database/databaseDriverManifest";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
export type UserAdminDialect = "mysql" | "postgres"; export type UserAdminDialect = "mysql" | "postgres";
export type PrivilegeScope = "mysql" | "database" | "schema" | "table" | "role"; export type PrivilegeScope = "mysql" | "database" | "schema" | "table" | "role";
@@ -33,40 +34,26 @@ export interface DatabaseUserAdminProvider {
parseUsers(result: QueryResult): DatabaseUserIdentity[]; parseUsers(result: QueryResult): DatabaseUserIdentity[];
parseFallbackUsers?: (result: QueryResult) => DatabaseUserIdentity[]; parseFallbackUsers?: (result: QueryResult) => DatabaseUserIdentity[];
showGrantsSql(user: DatabaseUserIdentity): string; showGrantsSql(user: DatabaseUserIdentity): string;
createUserSql(input: CreatePrincipalInput): string; parseGrants?(result: QueryResult): string[];
alterPasswordSql(user: DatabaseUserIdentity, password: string): string; createUserSql?(input: CreatePrincipalInput): string;
alterLoginSql(user: DatabaseUserIdentity, enabled: boolean): string; alterPasswordSql?(user: DatabaseUserIdentity, password: string): string;
dropUserSql(user: DatabaseUserIdentity): string; alterLoginSql?(user: DatabaseUserIdentity, enabled: boolean): string;
grantPrivilegesSql(input: PrivilegeChangeInput): string; dropUserSql?(user: DatabaseUserIdentity): string;
revokePrivilegesSql(input: PrivilegeChangeInput): string; grantPrivilegesSql?(input: PrivilegeChangeInput): string;
revokePrivilegesSql?(input: PrivilegeChangeInput): string;
label(user: DatabaseUserIdentity): string; label(user: DatabaseUserIdentity): string;
detail(user: DatabaseUserIdentity): string | undefined; detail(user: DatabaseUserIdentity): string | undefined;
privilegesForScope(scope: PrivilegeScope): readonly string[]; privilegesForScope?(scope: PrivilegeScope): readonly string[];
defaultPrivilegesForScope(scope: PrivilegeScope): string[]; defaultPrivilegesForScope?(scope: PrivilegeScope): string[];
} }
export const MYSQL_USER_ADMIN_TYPES = new Set<DatabaseType>(["mysql", "goldendb"]);
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 MYSQL_COMMON_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "EXECUTE", "SHOW VIEW", "TRIGGER", "EVENT", "CREATE TEMPORARY TABLES"] as const; 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 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;
export const POSTGRES_SCHEMA_PRIVILEGES = ["USAGE", "CREATE"] 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 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 (KINGBASE_USER_ADMIN_TYPES.has(dbType)) return kingbaseUserAdminProvider;
if (POSTGRES_USER_ADMIN_TYPES.has(dbType)) return postgresUserAdminProvider;
return null;
}
export function quoteSqlString(value: string): string { export function quoteSqlString(value: string): string {
return `'${value.replace(/'/g, "''")}'`; return `'${value.replace(/'/g, "''")}'`;
} }
@@ -103,6 +90,25 @@ export function mysqlListUsersFallbackSql(): string {
return "SELECT DISTINCT GRANTEE AS grantee FROM information_schema.USER_PRIVILEGES ORDER BY GRANTEE;"; return "SELECT DISTINCT GRANTEE AS grantee FROM information_schema.USER_PRIVILEGES ORDER BY GRANTEE;";
} }
export function starrocksListUsersSql(): string {
return "SHOW USERS;";
}
export function starrocksUsersResult(result: QueryResult): DatabaseUserIdentity[] {
const userIndex = columnIndex(result, "user", "User");
if (userIndex < 0) return [];
return result.rows.flatMap((row) => {
const parsed = parseMySqlGrantee(String(row[userIndex] ?? ""));
return parsed ? [parsed] : [];
});
}
export function starrocksGrantsResult(result: QueryResult): string[] {
const grantsIndex = columnIndex(result, "grants", "Grants");
if (grantsIndex < 0) return result.rows.map((row) => String(row[0] ?? "")).filter(Boolean);
return result.rows.map((row) => String(row[grantsIndex] ?? "")).filter(Boolean);
}
export function mysqlShowGrantsSql(user: DatabaseUserIdentity): string { export function mysqlShowGrantsSql(user: DatabaseUserIdentity): string {
return `SHOW GRANTS FOR ${mysqlUserAccount(user)};`; return `SHOW GRANTS FOR ${mysqlUserAccount(user)};`;
} }
@@ -142,6 +148,27 @@ export function mysqlRevokePrivilegesSql(input: PrivilegeChangeInput): string {
return `REVOKE ${privileges} ON ${mysqlPrivilegeTargetSql(input.database, input.table)} FROM ${mysqlUserAccount(input.user)};`; return `REVOKE ${privileges} ON ${mysqlPrivilegeTargetSql(input.database, input.table)} FROM ${mysqlUserAccount(input.user)};`;
} }
export function starrocksPrivilegeTargetSql(database: string, table = "*"): string {
const db = database.trim();
const tbl = table.trim();
if (!tbl || tbl === "*") {
return !db || db === "*" ? "ALL TABLES IN ALL DATABASES" : `ALL TABLES IN DATABASE ${quoteMySqlIdentifier(db)}`;
}
const tableName = quoteMySqlIdentifier(tbl);
return !db || db === "*" ? `TABLE ${tableName}` : `TABLE ${quoteMySqlIdentifier(db)}.${tableName}`;
}
export function starrocksGrantPrivilegesSql(input: PrivilegeChangeInput): string {
const privileges = normalizePrivileges(input.privileges).join(", ");
const grantOption = input.grantOption ? " WITH GRANT OPTION" : "";
return `GRANT ${privileges} ON ${starrocksPrivilegeTargetSql(input.database, input.table)} TO USER ${mysqlUserAccount(input.user)}${grantOption};`;
}
export function starrocksRevokePrivilegesSql(input: PrivilegeChangeInput): string {
const privileges = normalizePrivileges(input.privileges).join(", ");
return `REVOKE ${privileges} ON ${starrocksPrivilegeTargetSql(input.database, input.table)} FROM USER ${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]));
@@ -466,3 +493,52 @@ export const kingbaseUserAdminProvider: DatabaseUserAdminProvider = {
listUsersSql: kingbaseListRolesSql, listUsersSql: kingbaseListRolesSql,
showGrantsSql: kingbaseShowGrantsSql, showGrantsSql: kingbaseShowGrantsSql,
}; };
export const starrocksUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "mysql",
defaultScope: "table",
listUsersSql: starrocksListUsersSql,
parseUsers: starrocksUsersResult,
showGrantsSql: mysqlShowGrantsSql,
parseGrants: starrocksGrantsResult,
createUserSql: mysqlCreateUserSql,
alterPasswordSql: mysqlAlterUserPasswordSql,
dropUserSql: mysqlDropUserSql,
grantPrivilegesSql: starrocksGrantPrivilegesSql,
revokePrivilegesSql: starrocksRevokePrivilegesSql,
label: mysqlUserLabel,
detail: (user) => user.plugin,
privilegesForScope: () => STARROCKS_TABLE_PRIVILEGES,
defaultPrivilegesForScope: () => ["SELECT"],
};
const DATABASE_USER_ADMIN_PROVIDER_BY_TYPE = new Map<DatabaseType, DatabaseUserAdminProvider>([
["mysql", mysqlUserAdminProvider],
["goldendb", mysqlUserAdminProvider],
["kingbase", kingbaseUserAdminProvider],
["postgres", postgresUserAdminProvider],
["gaussdb", postgresUserAdminProvider],
["highgo", postgresUserAdminProvider],
["kwdb", postgresUserAdminProvider],
["opengauss", postgresUserAdminProvider],
["questdb", postgresUserAdminProvider],
["vastbase", postgresUserAdminProvider],
["starrocks", starrocksUserAdminProvider],
]);
export function getDatabaseUserAdminProvider(dbType: DatabaseType | undefined): DatabaseUserAdminProvider | null {
return dbType ? (DATABASE_USER_ADMIN_PROVIDER_BY_TYPE.get(dbType) ?? null) : null;
}
export function supportsDatabaseUserAdmin(dbType: DatabaseType | undefined): boolean {
return !!dbType && supportsDatabaseFeature(dbType, "userAdmin") && DATABASE_USER_ADMIN_PROVIDER_BY_TYPE.has(dbType);
}
export function resolveDatabaseUserAdminProviderForConnection(connection: ConnectionConfig | undefined): DatabaseUserAdminProvider | null {
const dbType = effectiveDatabaseTypeForConnection(connection);
return supportsDatabaseUserAdmin(dbType) ? getDatabaseUserAdminProvider(dbType) : null;
}
export function connectionSupportsDatabaseUserAdmin(connection: ConnectionConfig | undefined): boolean {
return resolveDatabaseUserAdminProviderForConnection(connection) !== null;
}
+2 -2
View File
@@ -75,7 +75,7 @@ import { hasTreeNodeDatabaseContext, normalizeCataloglessDatabaseNodes, treeNode
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/schemaTreeCache"; import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/schemaTreeCache";
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering"; import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
import { prunePinnedTreeNodeIdsForConnection } from "@/lib/app/pinnedTreeNodeIds"; import { prunePinnedTreeNodeIdsForConnection } from "@/lib/app/pinnedTreeNodeIds";
import { supportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin"; import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers"; import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
@@ -1027,7 +1027,7 @@ export const useConnectionStore = defineStore("connection", () => {
function buildUserAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined { function buildUserAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
const config = getConfig(connectionId); const config = getConfig(connectionId);
if (!supportsDatabaseUserAdmin(effectiveDatabaseTypeForConnection(config))) return undefined; if (!connectionSupportsDatabaseUserAdmin(config)) return undefined;
const existing = existingConnectionNode?.children?.find((child) => child.type === "user-admin"); const existing = existingConnectionNode?.children?.find((child) => child.type === "user-admin");
return { return {
id: `${connectionId}:__user_admin`, id: `${connectionId}:__user_admin`,
@@ -525,7 +525,7 @@
"databaseCreate": true, "databaseCreate": true,
"fieldLineage": false, "fieldLineage": false,
"sqlExplain": false, "sqlExplain": false,
"userAdmin": false, "userAdmin": true,
"driverManagement": false "driverManagement": false
} }
}, },
@@ -325,6 +325,9 @@ fn driver_manifest_declares_expected_product_capabilities() {
assert!(zookeeper.capabilities.query_execution); assert!(zookeeper.capabilities.query_execution);
assert!(zookeeper.capabilities.driver_management); assert!(zookeeper.capabilities.driver_management);
assert!(!zookeeper.capabilities.metadata_browse); assert!(!zookeeper.capabilities.metadata_browse);
let starrocks = find_driver(DatabaseType::StarRocks);
assert!(starrocks.capabilities.user_admin);
} }
#[test] #[test]
+97 -1
View File
@@ -1,7 +1,9 @@
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
import { test } from "vitest"; import { test } from "vitest";
import { import {
connectionSupportsDatabaseUserAdmin,
grantsFromQueryResult, grantsFromQueryResult,
getDatabaseUserAdminProvider,
mysqlAlterUserAccountLockSql, mysqlAlterUserAccountLockSql,
mysqlAlterUserPasswordSql, mysqlAlterUserPasswordSql,
mysqlCreateUserSql, mysqlCreateUserSql,
@@ -25,11 +27,18 @@ import {
quoteMySqlIdentifier, quoteMySqlIdentifier,
quoteMySqlString, quoteMySqlString,
quotePostgresIdentifier, quotePostgresIdentifier,
resolveDatabaseUserAdminProviderForConnection,
starrocksGrantPrivilegesSql,
starrocksGrantsResult,
starrocksListUsersSql,
starrocksPrivilegeTargetSql,
starrocksRevokePrivilegesSql,
starrocksUsersResult,
usersFromMySqlGranteeResult, usersFromMySqlGranteeResult,
usersFromMySqlUserResult, usersFromMySqlUserResult,
usersFromPostgresRolesResult, usersFromPostgresRolesResult,
} from "../../apps/desktop/src/lib/database/databaseUserAdmin.ts"; } from "../../apps/desktop/src/lib/database/databaseUserAdmin.ts";
import type { QueryResult } from "../../apps/desktop/src/types/database.ts"; import type { ConnectionConfig, QueryResult } from "../../apps/desktop/src/types/database.ts";
function result(columns: string[], rows: QueryResult["rows"]): QueryResult { function result(columns: string[], rows: QueryResult["rows"]): QueryResult {
return { return {
@@ -175,3 +184,90 @@ test("builds PostgreSQL role metadata SQL without directly requiring rolbypassrl
assert.match(grantsSql, /n\.nspname NOT LIKE 'pg~_%' ESCAPE '~'/); assert.match(grantsSql, /n\.nspname NOT LIKE 'pg~_%' ESCAPE '~'/);
assert.ok(!grantsSql.includes("ESCAPE '\\'")); assert.ok(!grantsSql.includes("ESCAPE '\\'"));
}); });
test("builds StarRocks user listing SQL", () => {
assert.equal(starrocksListUsersSql(), "SHOW USERS;");
});
test("builds StarRocks table privilege SQL", () => {
const input = {
user: { user: "reporter", host: "%" },
privileges: ["select", "EXPORT"],
database: "analytics",
table: "daily`rollup",
grantOption: true,
};
assert.equal(starrocksPrivilegeTargetSql("*"), "ALL TABLES IN ALL DATABASES");
assert.equal(starrocksPrivilegeTargetSql("analytics"), "ALL TABLES IN DATABASE `analytics`");
assert.equal(starrocksPrivilegeTargetSql("analytics", "daily`rollup"), "TABLE `analytics`.`daily``rollup`");
assert.equal(starrocksGrantPrivilegesSql(input), "GRANT SELECT, EXPORT ON TABLE `analytics`.`daily``rollup` TO USER 'reporter'@'%' WITH GRANT OPTION;");
assert.equal(starrocksRevokePrivilegesSql(input), "REVOKE SELECT, EXPORT ON TABLE `analytics`.`daily``rollup` FROM USER 'reporter'@'%';");
});
test("parses StarRocks SHOW USERS result", () => {
assert.deepEqual(starrocksUsersResult(result(["User"], [["'root'@'%'"], ["'grader_reader'@'%'"], ["'o''brien'@'localhost'"], ["malformed"]])), [
{ user: "root", host: "%" },
{ user: "grader_reader", host: "%" },
{ user: "o'brien", host: "localhost" },
]);
});
test("parses StarRocks SHOW GRANTS three-column result", () => {
assert.deepEqual(
starrocksGrantsResult(
result(
["UserIdentity", "Catalog", "Grants"],
[
["'root'@'%'", null, "GRANT 'root' TO 'root'@'%'"],
["'grader_reader'@'%'", "default_catalog", "GRANT SELECT ON ALL TABLES IN DATABASE grader_events TO USER 'grader_reader'@'%'"],
["'reader'@'%'", "paimon", null],
],
),
),
["GRANT 'root' TO 'root'@'%'", "GRANT SELECT ON ALL TABLES IN DATABASE grader_events TO USER 'grader_reader'@'%'"],
);
});
test("StarRocks grants parser falls back to first column when Grants column is absent", () => {
assert.deepEqual(starrocksGrantsResult(result(["Grants for app@%"], [["GRANT SELECT ON `app`.* TO 'app'@'%'"]])), ["GRANT SELECT ON `app`.* TO 'app'@'%'"]);
});
test("routes StarRocks to the StarRocks user admin provider", () => {
const provider = getDatabaseUserAdminProvider("starrocks");
assert.ok(provider, "expected a provider for starrocks");
assert.equal(provider?.dialect, "mysql");
assert.equal(provider?.listUsersSql(), "SHOW USERS;");
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(provider?.dropUserSql?.({ user: "app", host: "%" }), "DROP USER 'app'@'%';");
assert.equal(
provider?.grantPrivilegesSql?.({
user: { user: "reporter", host: "%" },
privileges: ["SELECT"],
database: "analytics",
}),
"GRANT SELECT ON ALL TABLES IN DATABASE `analytics` TO USER 'reporter'@'%';",
);
assert.equal(provider?.alterLoginSql, undefined);
assert.deepEqual(provider?.privilegesForScope?.("table"), ["SELECT", "INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "EXPORT", "ALL"]);
});
test("resolves user admin support from the effective connection type", () => {
const mysqlProtocolStarRocks = {
id: "starrocks-1",
db_type: "mysql",
driver_profile: "starrocks",
} as ConnectionConfig;
const jdbcStarRocks = {
id: "starrocks-jdbc-1",
db_type: "jdbc",
connection_string: "jdbc:mysql://localhost:9030/analytics",
driver_profile: "starrocks",
} as ConnectionConfig;
assert.equal(resolveDatabaseUserAdminProviderForConnection(mysqlProtocolStarRocks), getDatabaseUserAdminProvider("starrocks"));
assert.equal(resolveDatabaseUserAdminProviderForConnection(jdbcStarRocks), getDatabaseUserAdminProvider("starrocks"));
assert.equal(connectionSupportsDatabaseUserAdmin(mysqlProtocolStarRocks), true);
assert.equal(connectionSupportsDatabaseUserAdmin({ id: "sqlite-1", db_type: "sqlite" } as ConnectionConfig), false);
});