feat(admin): add table grants for postgres and starrocks

Closes #10251
This commit is contained in:
t8y2
2026-09-27 12:04:10 +08:00
committed by t8y2
parent 627a8498d0
commit deaa867498
7 changed files with 1106 additions and 151 deletions
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import * as api from "@/lib/backend/api";
import { authorizationPrivileges, type AuthorizationPreset, type DatabaseAuthorizationSelection } from "@/lib/database/databaseAuthorizationPlan";
import { authorizationPresetPrivileges, authorizationPrivileges, type AuthorizationDatabaseOption, type AuthorizationPreset, type AuthorizationTableSelection, type DatabaseAuthorizationSelection } from "@/lib/database/databaseAuthorizationPlan";
import type { DatabaseUserAdminProvider } from "@/lib/database/databaseUserAdmin";
/**
@@ -21,7 +21,7 @@ const props = defineProps<{
/** 当前连接的权限提供者,用于判断是否支持表级授权以及表级可用权限 */
provider: DatabaseUserAdminProvider | null;
/** 当前连接下的数据库名列表 */
databases: string[];
databases: AuthorizationDatabaseOption[];
/** 数据库列表是否正在加载 */
databasesLoading?: boolean;
/** 连接 ID,用于按库懒加载表列表 */
@@ -37,9 +37,10 @@ const emit = defineEmits<{
const { t } = useI18n();
// 全局作用域标识:MySQL 的 GRANT/REVOKE 支持 ON *.*,权限编辑面板需要保留这一能力
const GLOBAL_SCOPE = "*";
const GLOBAL_OPTION: AuthorizationDatabaseOption = { database: GLOBAL_SCOPE };
const search = ref("");
// 表列表按库懒加载并缓存,避免下拉展开时重复请求
const tables = ref<Record<string, string[]>>({});
const tables = ref<Record<string, AuthorizationTableSelection[]>>({});
const tablesLoading = ref<Record<string, boolean>>({});
const tableErrors = ref<Record<string, string>>({});
const tableSearch = ref<Record<string, string>>({});
@@ -47,12 +48,12 @@ const tableSearch = ref<Record<string, string>>({});
const supportsTableGrants = computed(() => !!props.provider?.supportsTableGrantsOnCreate);
const filteredDatabases = computed(() => {
const query = search.value.trim().toLowerCase();
return query ? props.databases.filter((database) => database.toLowerCase().includes(query)) : props.databases;
return query ? props.databases.filter((database) => databaseLabel(database).toLowerCase().includes(query)) : props.databases;
});
// 紧凑模式(权限编辑面板)在列表顶部提供固定的「全局」入口,且不受库搜索影响;
// 新增用户弹窗保持只勾选真实数据库,不改变原有产品行为。
const visibleDatabases = computed(() => (props.compact ? [GLOBAL_SCOPE, ...filteredDatabases.value] : filteredDatabases.value));
const selectedDatabaseSet = computed(() => new Set(props.modelValue.map((selection) => selection.database)));
const visibleDatabases = computed(() => (props.compact && props.provider?.authorizationModel === "mysql" ? [GLOBAL_OPTION, ...filteredDatabases.value] : filteredDatabases.value));
const selectedDatabaseSet = computed(() => new Set(props.modelValue.map(authorizationSelectionKey)));
// 同一 tick 内连续触发多次选择变更时(例如连续点击多张表),父组件回流的 props 尚未更新,
// 这里用最近一次发出的值作为基准,避免后一次变更覆盖前一次导致选择丢失。
@@ -61,6 +62,19 @@ watch(
() => props.modelValue,
(value) => {
pendingSelections = value;
for (const selection of value) {
if (selection.tables !== undefined) void loadTables({ database: selection.database, ...(selection.catalog ? { catalog: selection.catalog } : {}) });
}
},
{ immediate: true },
);
watch(
() => props.databases,
() => {
for (const selection of props.modelValue) {
if (selection.tables !== undefined) void loadTables({ database: selection.database, ...(selection.catalog ? { catalog: selection.catalog } : {}) });
}
},
);
@@ -73,33 +87,34 @@ function commitSelections(next: DatabaseAuthorizationSelection[]) {
emit("update:modelValue", next);
}
function selectionFor(database: string): DatabaseAuthorizationSelection | undefined {
return props.modelValue.find((selection) => selection.database === database);
function selectionFor(database: AuthorizationDatabaseOption): DatabaseAuthorizationSelection | undefined {
const key = authorizationOptionKey(database);
return props.modelValue.find((selection) => authorizationSelectionKey(selection) === key);
}
/**
* 统一以“不可变更新”方式回写选中项:始终替换为新的数组与对象,
* 避免直接修改 props 中的对象引用(v-model 单向数据流的约束)。
*/
function patchSelection(database: string, patch: (selection: DatabaseAuthorizationSelection) => DatabaseAuthorizationSelection | undefined) {
function patchSelection(database: AuthorizationDatabaseOption, patch: (selection: DatabaseAuthorizationSelection) => DatabaseAuthorizationSelection | undefined) {
const next: DatabaseAuthorizationSelection[] = [];
for (const selection of currentSelections()) {
if (selection.database !== database) {
if (authorizationSelectionKey(selection) !== authorizationOptionKey(database)) {
next.push(selection);
continue;
}
const updated = patch({ ...selection });
if (updated) next.push({ ...updated, tables: updated.tables ? [...updated.tables] : undefined, privileges: updated.privileges ? [...updated.privileges] : undefined });
if (updated) next.push({ ...updated, tables: updated.tables?.map((table) => ({ ...table })), privileges: updated.privileges ? [...updated.privileges] : undefined });
}
commitSelections(next);
}
function toggleDatabase(database: string) {
function toggleDatabase(database: AuthorizationDatabaseOption) {
if (selectionFor(database)) {
commitSelections(currentSelections().filter((selection) => selection.database !== database));
commitSelections(currentSelections().filter((selection) => authorizationSelectionKey(selection) !== authorizationOptionKey(database)));
return;
}
commitSelections([...currentSelections(), { database, preset: "readOnly", privileges: ["SELECT"] }]);
commitSelections([...currentSelections(), { database: database.database, ...(database.catalog ? { catalog: database.catalog } : {}), preset: "readOnly", privileges: ["SELECT"] }]);
}
// 表范围下 MySQL 不支持 CREATE ROUTINE / EVENT 等库级权限,需要按目标作用域过滤可选权限
@@ -109,43 +124,63 @@ function privilegesForSelection(selection?: DatabaseAuthorizationSelection): str
return authorizationPrivileges(userProvider, selection?.tables === undefined ? "database" : "table");
}
function usesSelectedTables(database: string): boolean {
function usesSelectedTables(database: AuthorizationDatabaseOption): boolean {
return selectionFor(database)?.tables !== undefined;
}
function isTableSelected(database: string, table: string): boolean {
return selectionFor(database)?.tables?.includes(table) ?? false;
function isTableSelected(database: AuthorizationDatabaseOption, table: AuthorizationTableSelection): boolean {
return selectionFor(database)?.tables?.some((selected) => authorizationTableKey(selected) === authorizationTableKey(table)) ?? false;
}
function allTablesSelected(database: string): boolean {
const available = tables.value[database] ?? [];
return available.length > 0 && selectionFor(database)?.tables?.length === available.length;
function allTablesSelected(database: AuthorizationDatabaseOption): boolean {
const key = authorizationOptionKey(database);
const available = tables.value[key] ?? [];
const selected = new Set(selectionFor(database)?.tables?.map(authorizationTableKey) ?? []);
return available.length > 0 && available.every((table) => selected.has(authorizationTableKey(table)));
}
function filteredTables(database: string): string[] {
const query = (tableSearch.value[database] ?? "").trim().toLowerCase();
const available = tables.value[database] ?? [];
return query ? available.filter((table) => table.toLowerCase().includes(query)) : available;
function filteredTables(database: AuthorizationDatabaseOption): AuthorizationTableSelection[] {
const key = authorizationOptionKey(database);
const query = (tableSearch.value[key] ?? "").trim().toLowerCase();
const available = tables.value[key] ?? [];
return query ? available.filter((table) => tableLabel(table).toLowerCase().includes(query)) : available;
}
async function loadTables(database: string) {
if (tables.value[database] || tablesLoading.value[database]) return;
tablesLoading.value = { ...tablesLoading.value, [database]: true };
tableErrors.value = { ...tableErrors.value, [database]: "" };
async function loadTables(database: AuthorizationDatabaseOption) {
const key = authorizationOptionKey(database);
if (tables.value[key] || tablesLoading.value[key]) return;
tablesLoading.value = { ...tablesLoading.value, [key]: true };
tableErrors.value = { ...tableErrors.value, [key]: "" };
try {
const result = await api.listTables(props.connectionId, database, "");
let result: AuthorizationTableSelection[];
if (props.provider?.authorizationModel === "postgres") {
const schemas = (await api.listSchemas(props.connectionId, database.database)).filter(isPostgresGrantSchema);
const schemaTables = await Promise.all(
schemas.map(async (schema) => {
const items = await api.listTables(props.connectionId, database.database, schema, undefined, undefined, undefined, ["TABLE"]);
return items.map((table) => ({ name: table.name, schema }));
}),
);
result = schemaTables.flat();
} else if (props.provider?.authorizationModel === "starrocks") {
const items = await api.listTables(props.connectionId, database.database, "", undefined, undefined, undefined, ["TABLE"], database.catalog);
result = items.map((table) => ({ name: table.name }));
} else {
const items = await api.listTables(props.connectionId, database.database, "");
result = items.map((table) => ({ name: table.name }));
}
tables.value = {
...tables.value,
[database]: Array.from(new Set(result.map((table) => table.name.trim()).filter(Boolean))).sort((left, right) => left.localeCompare(right)),
[key]: uniqueTableOptions(result).sort((left, right) => tableLabel(left).localeCompare(tableLabel(right))),
};
} catch (error: any) {
tableErrors.value = { ...tableErrors.value, [database]: error?.message || String(error) };
tableErrors.value = { ...tableErrors.value, [key]: error?.message || String(error) };
} finally {
tablesLoading.value = { ...tablesLoading.value, [database]: false };
tablesLoading.value = { ...tablesLoading.value, [key]: false };
}
}
async function updateTableScope(database: string, selectTables: boolean) {
async function updateTableScope(database: AuthorizationDatabaseOption, selectTables: boolean) {
if (!selectTables) {
patchSelection(database, (selection) => {
delete selection.tables;
@@ -155,6 +190,7 @@ async function updateTableScope(database: string, selectTables: boolean) {
}
patchSelection(database, (selection) => {
selection.tables = selection.tables ?? [];
if (authorizationPresetPrivileges(props.provider!, selection.preset, selection.privileges, "table").length === 0) selection.preset = "readOnly";
if (selection.preset === "custom") {
const allowed = new Set(privilegesForSelection(selection));
selection.privileges = (selection.privileges ?? []).filter((privilege) => allowed.has(privilege));
@@ -164,27 +200,25 @@ async function updateTableScope(database: string, selectTables: boolean) {
await loadTables(database);
}
function toggleTable(database: string, table: string) {
function toggleTable(database: AuthorizationDatabaseOption, table: AuthorizationTableSelection) {
patchSelection(database, (selection) => {
if (!selection.tables) return selection;
const next = new Set(selection.tables);
if (next.has(table)) next.delete(table);
else next.add(table);
selection.tables = Array.from(next);
const key = authorizationTableKey(table);
selection.tables = selection.tables.some((selected) => authorizationTableKey(selected) === key) ? selection.tables.filter((selected) => authorizationTableKey(selected) !== key) : [...selection.tables, table];
return selection;
});
}
function toggleAllTables(database: string) {
function toggleAllTables(database: AuthorizationDatabaseOption) {
patchSelection(database, (selection) => {
if (!selection.tables) return selection;
const available = tables.value[database] ?? [];
selection.tables = selection.tables.length === available.length ? [] : [...available];
const available = tables.value[authorizationOptionKey(database)] ?? [];
selection.tables = allTablesSelected(database) ? [] : [...available];
return selection;
});
}
function updatePreset(database: string, preset: unknown) {
function updatePreset(database: AuthorizationDatabaseOption, preset: unknown) {
if (typeof preset !== "string") return;
patchSelection(database, (selection) => {
selection.preset = preset as AuthorizationPreset;
@@ -192,7 +226,7 @@ function updatePreset(database: string, preset: unknown) {
});
}
function togglePrivilege(database: string, privilege: string) {
function togglePrivilege(database: AuthorizationDatabaseOption, privilege: string) {
patchSelection(database, (selection) => {
const next = new Set(selection.privileges ?? []);
if (next.has(privilege)) next.delete(privilege);
@@ -202,9 +236,45 @@ function togglePrivilege(database: string, privilege: string) {
});
}
function isPrivilegeSelected(database: string, privilege: string): boolean {
function isPrivilegeSelected(database: AuthorizationDatabaseOption, privilege: string): boolean {
return selectionFor(database)?.privileges?.includes(privilege) ?? false;
}
function authorizationOptionKey(option: AuthorizationDatabaseOption): string {
return JSON.stringify([option.catalog ?? "", option.database]);
}
function authorizationSelectionKey(selection: Pick<DatabaseAuthorizationSelection, "catalog" | "database">): string {
return authorizationOptionKey(selection);
}
function databaseLabel(option: AuthorizationDatabaseOption): string {
return option.catalog ? `${option.catalog} / ${option.database}` : option.database;
}
function authorizationTableKey(table: AuthorizationTableSelection): string {
return JSON.stringify([table.schema ?? "", table.name]);
}
function tableLabel(table: AuthorizationTableSelection): string {
return table.schema ? `${table.schema}.${table.name}` : table.name;
}
function uniqueTableOptions(options: AuthorizationTableSelection[]): AuthorizationTableSelection[] {
const seen = new Set<string>();
return options.filter((option) => {
if (!option.name.trim()) return false;
const key = authorizationTableKey(option);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function isPostgresGrantSchema(schema: string): boolean {
const normalized = schema.toLowerCase();
return !!schema.trim() && normalized !== "information_schema" && normalized !== "sys_catalog" && !normalized.startsWith("pg_");
}
</script>
<template>
@@ -227,13 +297,13 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
{{ t("userAdmin.loadingDatabases") }}
</div>
<div v-else-if="visibleDatabases.length === 0" class="p-3 text-center text-xs text-muted-foreground">{{ t("userAdmin.emptyDatabases") }}</div>
<div v-for="database in visibleDatabases" :key="database" class="border-b p-2" :class="compact ? '' : 'last:border-b-0'">
<div v-for="database in visibleDatabases" :key="authorizationOptionKey(database)" class="border-b p-2" :class="compact ? '' : 'last:border-b-0'">
<div class="flex items-center gap-2">
<input :checked="selectedDatabaseSet.has(database)" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleDatabase(database)" />
<input :checked="selectedDatabaseSet.has(authorizationOptionKey(database))" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleDatabase(database)" />
<button type="button" class="min-w-0 flex-1 truncate text-left text-xs font-medium" @click="toggleDatabase(database)">
{{ database === GLOBAL_SCOPE ? t("userAdmin.globalScope") : database }}
{{ database.database === GLOBAL_SCOPE ? t("userAdmin.globalScope") : databaseLabel(database) }}
</button>
<Select v-if="selectedDatabaseSet.has(database)" :model-value="selectionFor(database)?.preset" @update:model-value="updatePreset(database, $event)">
<Select v-if="selectedDatabaseSet.has(authorizationOptionKey(database))" :model-value="selectionFor(database)?.preset" @update:model-value="updatePreset(database, $event)">
<SelectTrigger class="h-7 text-xs" :class="compact ? 'w-28' : 'w-32'"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="readWrite">{{ t("userAdmin.presetReadWrite") }}</SelectItem>
@@ -244,7 +314,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
</SelectContent>
</Select>
</div>
<div v-if="supportsTableGrants && database !== GLOBAL_SCOPE && selectedDatabaseSet.has(database)" class="mt-2 flex gap-2" :class="compact ? 'flex-col gap-1.5' : 'items-center pl-5'">
<div v-if="supportsTableGrants && database.database !== GLOBAL_SCOPE && selectedDatabaseSet.has(authorizationOptionKey(database))" class="mt-2 flex gap-2" :class="compact ? 'flex-col gap-1.5' : 'items-center pl-5'">
<div class="flex items-center gap-2">
<span v-if="!compact" class="shrink-0 text-[11px] text-muted-foreground">{{ t("userAdmin.tableScope") }}</span>
<div class="flex h-7 shrink-0 items-center rounded-md border bg-muted/30 p-0.5">
@@ -263,7 +333,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
<span class="min-w-0 flex-1 truncate">
{{ selectionFor(database)?.tables?.length ? t("userAdmin.selectedTableCount", { count: selectionFor(database)?.tables?.length }) : t("userAdmin.chooseTables") }}
</span>
<Loader2 v-if="tablesLoading[database]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<Loader2 v-if="tablesLoading[authorizationOptionKey(database)]" class="h-3.5 w-3.5 shrink-0 animate-spin" />
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
@@ -271,29 +341,29 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
<div class="flex items-center gap-2 border-b p-2">
<div class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border px-2">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input v-model="tableSearch[database]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
<input v-model="tableSearch[authorizationOptionKey(database)]" class="min-w-0 flex-1 bg-transparent text-[11px] outline-none" :placeholder="t('userAdmin.searchTable')" />
</div>
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[database]?.length > 0)" @click="toggleAllTables(database)">
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[authorizationOptionKey(database)]?.length > 0)" @click="toggleAllTables(database)">
{{ t(allTablesSelected(database) ? "userAdmin.clearAllTables" : "userAdmin.selectAllTables") }}
</button>
</div>
<div class="max-h-56 overflow-auto p-1">
<div v-if="tablesLoading[database]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
<div v-if="tablesLoading[authorizationOptionKey(database)]" class="flex items-center justify-center gap-2 px-3 py-6 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("userAdmin.loadingTables") }}
</div>
<div v-else-if="tableErrors[database]" class="px-3 py-4 text-center text-xs text-destructive">
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: tableErrors[database] }) }}</p>
<div v-else-if="tableErrors[authorizationOptionKey(database)]" class="px-3 py-4 text-center text-xs text-destructive">
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: tableErrors[authorizationOptionKey(database)] }) }}</p>
<button type="button" class="mt-2 text-primary" @click="loadTables(database)">{{ t("userAdmin.retry") }}</button>
</div>
<div v-else-if="filteredTables(database).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
{{ t("userAdmin.emptyTables") }}
</div>
<button v-for="table in filteredTables(database)" :key="table" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleTable(database, table)">
<button v-for="table in filteredTables(database)" :key="authorizationTableKey(table)" type="button" class="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs hover:bg-accent" @click="toggleTable(database, table)">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border" :class="isTableSelected(database, table) ? 'border-primary bg-primary text-primary-foreground' : 'border-border'">
<Check v-if="isTableSelected(database, table)" class="h-2.5 w-2.5" />
</span>
<span class="min-w-0 flex-1 truncate">{{ table }}</span>
<span class="min-w-0 flex-1 truncate">{{ tableLabel(table) }}</span>
</button>
</div>
</PopoverContent>
@@ -319,7 +389,7 @@ function isPrivilegeSelected(database: string, privilege: string): boolean {
{{ t("userAdmin.customPrivilegeRequired") }}
</p>
</div>
<p v-if="provider?.dialect === 'postgres' && selectedDatabaseSet.has(database) && selectionFor(database)?.preset === 'ddl'" class="mt-2 text-[10px] text-muted-foreground" :class="compact ? '' : 'pl-5'">
<p v-if="provider?.dialect === 'postgres' && selectedDatabaseSet.has(authorizationOptionKey(database)) && selectionFor(database)?.preset === 'ddl'" class="mt-2 text-[10px] text-muted-foreground" :class="compact ? '' : 'pl-5'">
{{ t("userAdmin.postgresDdlHint") }}
</p>
</div>
@@ -16,15 +16,18 @@ import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { grantsFromQueryResult, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
import { grantsFromQueryResult, normalizeStarrocksCatalog, resolveDatabaseUserAdminProviderForConnection, type DatabaseTablePrivilegeGrant, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
import {
authorizationPlanSql,
authorizationPlanStatus,
authorizationPresetPrivileges,
authorizationPrivileges,
buildCreateUserAuthorizationPlan,
buildGrantAuthorizationPlan,
databaseAuthorizationsFromTableGrants,
executeAuthorizationPlan,
type AuthorizationAccountType,
type AuthorizationDatabaseOption,
type AuthorizationPlan,
type AuthorizationStepResult,
type DatabaseAuthorizationSelection,
@@ -88,12 +91,19 @@ const grantOption = ref(restoredUiState.grantOption ?? false);
const selectedPrivileges = ref<string[]>(restoredUiState.selectedPrivileges ?? ["SELECT"]);
const createCanLogin = ref(true);
const createAccountType = ref<AuthorizationAccountType>("standard");
const createDatabases = ref<string[]>([]);
const createDatabases = ref<AuthorizationDatabaseOption[]>([]);
const createDatabasesLoading = ref(false);
const createDatabasesLoadError = ref("");
const createDatabaseAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
// 权限编辑面板的授权范围选择(MySQL 表级授权场景),与新增用户弹窗复用同一个范围编辑器
// 权限编辑面板的授权范围选择,与新增用户弹窗复用同一个范围编辑器
const privilegeAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
const currentTableGrants = ref<DatabaseTablePrivilegeGrant[]>([]);
const currentTableGrantsLoaded = ref(false);
const currentTableGrantsLoading = ref(false);
const tableGrantLoadError = ref("");
let createPlanRequestId = 0;
let tableGrantRequestId = 0;
let databaseLoadPromise: Promise<void> | undefined;
let preserveRestoredPrivilegeSelection = restoredUiState.selectedPrivileges !== undefined || restoredUiState.grantOption !== undefined;
trackUiState(() => ({
@@ -139,9 +149,9 @@ const grantsSqlText = computed(() => grants.value.join("\n") || t("userAdmin.noG
const highlightedGrantsSql = computed(() => highlight(grantsSqlText.value));
const highlightedPendingSql = computed(() => highlight(pendingSql.value));
const createDatabaseAuthorizationsValid = computed(() => authorizationSelectionsValid(createDatabaseAuthorizations.value));
// MySQL 表级授权场景下,权限编辑面板复用与新增用户一致的授权范围编辑器
const usePrivilegeScopeEditor = computed(() => canEditPrivileges.value && supportsCreateTableGrants.value && privilegeScope.value !== "role");
const usePrivilegeScopeEditor = computed(() => canEditPrivileges.value && supportsCreateTableGrants.value && privilegeScope.value !== "role" && (provider.value?.authorizationModel !== "postgres" || privilegeScope.value === "table"));
const privilegeAuthorizationsValid = computed(() => authorizationSelectionsValid(privilegeAuthorizations.value));
const tableGrantDiffReady = computed(() => provider.value?.authorizationModel === "mysql" || currentTableGrantsLoaded.value);
const pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined));
const canPreviewHostChange = computed(() => {
const host = newHost.value.trim();
@@ -221,23 +231,94 @@ async function loadGrants() {
loadingGrants.value = true;
grantsLoaded.value = false;
grantError.value = "";
if (userProvider.authorizationModel !== "mysql") {
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
currentTableGrantsLoading.value = false;
tableGrantLoadError.value = "";
}
try {
const result = await api.executeQuery(props.connection.id, "", userProvider.showGrantsSql(user), undefined, undefined, {
maxRows: 1000,
});
grants.value = (userProvider.parseGrants ?? grantsFromQueryResult)(result);
grantsLoaded.value = true;
if (userProvider.tableGrantsFromShowGrants && userProvider.parseTableGrants) {
currentTableGrants.value = userProvider.parseTableGrants(result);
currentTableGrantsLoaded.value = true;
tableGrantLoadError.value = "";
syncAuthorizationSelectionsFromTableGrants();
}
// Existing-user editing reflects the exact SHOW GRANTS scope; create-user defaults stay independent.
syncPrivilegeSelectionFromGrants();
if (userProvider.authorizationModel === "postgres" && usePrivilegeScopeEditor.value) await loadCurrentTableGrants();
} catch (error: any) {
grantError.value = error?.message || String(error);
grants.value = [];
grantsLoaded.value = false;
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
} finally {
loadingGrants.value = false;
}
}
async function loadCurrentTableGrants() {
const user = selectedUser.value;
const userProvider = provider.value;
if (!user || !userProvider?.tableGrantsSql || !userProvider.parseTableGrants || currentTableGrantsLoading.value) return;
const requestId = ++tableGrantRequestId;
const selectedKey = userKey(user);
currentTableGrantsLoading.value = true;
currentTableGrantsLoaded.value = false;
tableGrantLoadError.value = "";
try {
if (createDatabases.value.length === 0) await loadDatabases();
if (createDatabasesLoadError.value) throw new Error(createDatabasesLoadError.value);
const grantsByDatabase = await Promise.all(
createDatabases.value.map(async ({ database }) => {
const result = await api.executeQuery(props.connection.id, database, userProvider.tableGrantsSql!(user), undefined, undefined, { maxRows: 10000 });
return userProvider.parseTableGrants!(result, { database });
}),
);
if (requestId !== tableGrantRequestId || selectedUserKey.value !== selectedKey) return;
currentTableGrants.value = grantsByDatabase.flat();
currentTableGrantsLoaded.value = true;
syncAuthorizationSelectionsFromTableGrants();
} catch (error: any) {
if (requestId !== tableGrantRequestId || selectedUserKey.value !== selectedKey) return;
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
tableGrantLoadError.value = error?.message || String(error);
} finally {
if (requestId === tableGrantRequestId) currentTableGrantsLoading.value = false;
}
}
function syncAuthorizationSelectionsFromTableGrants() {
const userProvider = provider.value;
if (!userProvider || createDatabases.value.length === 0 || privilegeAuthorizations.value.length > 0) return;
const loaded = databaseAuthorizationsFromTableGrants(userProvider, currentTableGrants.value);
const available = new Map(createDatabases.value.map((option) => [authorizationDatabaseOptionKey(option), option]));
const selections = loaded.selections.flatMap((selection) => {
const option = available.get(authorizationDatabaseOptionKey(selection));
if (!option) return [];
return [{ ...selection, catalog: option.catalog }];
});
privilegeAuthorizations.value = selections;
if (selections.length > 0) grantOption.value = loaded.grantOption;
}
function authorizationDatabaseOptionKey(option: AuthorizationDatabaseOption): string {
const catalog = provider.value?.authorizationModel === "starrocks" ? (normalizeStarrocksCatalog(option.catalog ?? "") ?? "default_catalog") : (option.catalog ?? "");
return JSON.stringify([catalog, option.database]);
}
async function preparePrivilegeScopeEditor() {
if (createDatabases.value.length === 0) await loadDatabases();
if (provider.value?.authorizationModel === "postgres" && !currentTableGrantsLoaded.value) await loadCurrentTableGrants();
}
function selectUser(user: DatabaseUserIdentity) {
selectedUserKey.value = userKey(user);
}
@@ -257,19 +338,39 @@ function togglePrivilege(privilege: string) {
}
// 加载当前连接下的数据库列表:新增用户弹窗与权限编辑面板的授权范围编辑器共用
async function loadDatabases() {
if (createDatabasesLoading.value) return;
createDatabases.value = [];
createDatabasesLoading.value = true;
try {
await ensureConnection();
const config = props.connection;
createDatabases.value = config.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(config.id, config) : (await api.listDatabases(config.id)).map((database) => database.name);
} catch (error: any) {
toast(t("userAdmin.loadDatabasesFailed", { message: error?.message || String(error) }), 5000);
} finally {
createDatabasesLoading.value = false;
}
function loadDatabases(): Promise<void> {
if (databaseLoadPromise) return databaseLoadPromise;
let request!: Promise<void>;
request = (async () => {
createDatabases.value = [];
createDatabasesLoading.value = true;
createDatabasesLoadError.value = "";
try {
await ensureConnection();
const config = props.connection;
if (provider.value?.authorizationModel === "starrocks") {
const catalogs = await api.listDorisCatalogs(config.id);
if (catalogs.length > 0) {
const catalogDatabases = await Promise.all(catalogs.map(async (catalog) => (await api.listDorisCatalogDatabases(config.id, catalog.name)).map((database) => ({ catalog: catalog.name, database: database.name }))));
createDatabases.value = catalogDatabases.flat();
} else {
createDatabases.value = (await api.listDatabases(config.id)).map((database) => ({ database: database.name }));
}
} else {
const databases = config.db_type === "dameng" ? await fetchNamespaceOptionsForConnection(config.id, config) : (await api.listDatabases(config.id)).map((database) => database.name);
createDatabases.value = databases.map((database) => ({ database }));
}
if (currentTableGrantsLoaded.value) syncAuthorizationSelectionsFromTableGrants();
} catch (error: any) {
createDatabasesLoadError.value = error?.message || String(error);
toast(t("userAdmin.loadDatabasesFailed", { message: createDatabasesLoadError.value }), 5000);
} finally {
createDatabasesLoading.value = false;
if (databaseLoadPromise === request) databaseLoadPromise = undefined;
}
})();
databaseLoadPromise = request;
return request;
}
async function openCreateUserDialog() {
@@ -290,11 +391,23 @@ function authorizationSelectionsValid(selections: DatabaseAuthorizationSelection
if (!userProvider) return false;
return selections.every((selection) => {
if (selection.tables !== undefined && selection.tables.length === 0) return false;
if (selection.preset !== "custom") return true;
return authorizationPrivileges(userProvider, selection.tables === undefined ? "database" : "table").some((privilege) => selection.privileges?.includes(privilege));
const targetScope = selection.tables === undefined && userProvider.authorizationModel !== "starrocks" ? "database" : "table";
const available = authorizationPrivileges(userProvider, targetScope);
if (selection.preset !== "custom") return authorizationPresetPrivileges(userProvider, selection.preset, selection.privileges, targetScope).length > 0;
return available.some((privilege) => selection.privileges?.includes(privilege));
});
}
async function prepareAuthorizationSelections(selections: DatabaseAuthorizationSelection[]): Promise<DatabaseAuthorizationSelection[]> {
if (provider.value?.authorizationModel !== "postgres") return selections;
return Promise.all(
selections.map(async (selection) => ({
...selection,
schemas: selection.tables === undefined ? await api.listSchemas(props.connection.id, selection.database) : Array.from(new Set(selection.tables.flatMap((table) => (table.schema ? [table.schema] : [])))),
})),
);
}
function previewSql(sql: string, options: { danger?: boolean; afterApply?: () => Promise<void> } = {}) {
pendingSql.value = sql;
pendingPlan.value = undefined;
@@ -315,7 +428,7 @@ async function applyPendingSql() {
source: t("production.sourceAdmin"),
execute: async () => {
if (pendingPlan.value) {
return executeAuthorizationPlan(pendingPlan.value, (step) => api.executeMulti(props.connection.id, step.database, step.sql, undefined, undefined, { maxRows: 1000, continueOnError: true }));
return executeAuthorizationPlan(pendingPlan.value, (step) => api.executeMulti(props.connection.id, step.database, step.sql, undefined, undefined, { maxRows: 1000, catalog: step.targetCatalog, continueOnError: step.targetCatalog ? false : true }));
}
const queryResults = await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000, continueOnError: true });
const failed = queryResults.find((item) => item.execution_error === true);
@@ -392,12 +505,15 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
if (step.operation === "createUser") return t("userAdmin.stepCreateUser");
if (step.operation === "grantAdmin") return t("userAdmin.stepGrantAdmin", { user: step.subject });
if (step.operation === "grantDatabase") {
return step.targetTable ? t("userAdmin.stepGrantTable", { user: step.subject, database: step.targetDatabase, table: step.targetTable }) : t("userAdmin.stepGrantDatabase", { user: step.subject, database: step.targetDatabase });
const database = authorizationStepDatabase(step);
return step.targetTable ? t("userAdmin.stepGrantTable", { user: step.subject, database, table: step.targetTable }) : t("userAdmin.stepGrantDatabase", { user: step.subject, database });
}
if (step.operation === "revokePrivileges") {
return step.targetTable ? t("userAdmin.stepRevokeTable", { user: step.subject, database: step.targetDatabase, table: step.targetTable }) : t("userAdmin.stepRevokeDatabase", { user: step.subject, database: step.targetDatabase });
const database = authorizationStepDatabase(step);
return step.targetTable ? t("userAdmin.stepRevokeTable", { user: step.subject, database, table: step.targetTable }) : t("userAdmin.stepRevokeDatabase", { user: step.subject, database });
}
if (step.operation === "grantCurrentObjects") {
if (step.targetTable) return t("userAdmin.stepGrantTable", { user: step.subject, database: authorizationStepDatabase(step), table: step.targetTable });
return t("userAdmin.stepGrantCurrentObjects", {
user: step.subject,
database: step.targetDatabase,
@@ -413,6 +529,10 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
return step.label;
}
function authorizationStepDatabase(step: AuthorizationStepResult["step"]): string {
return [step.targetCatalog, step.targetDatabase, step.targetSchema].filter(Boolean).join(".");
}
function authorizationObjectScopeLabel(scope: AuthorizationStepResult["step"]["objectScope"]): string {
if (scope === "schemas") return t("userAdmin.scopeSchemas");
if (scope === "tables") return t("userAdmin.scopeTables");
@@ -480,15 +600,23 @@ function previewPlan(plan: AuthorizationPlan, options: { danger?: boolean } = {}
sqlDialogOpen.value = true;
}
function previewGrant() {
async function previewGrant() {
const user = selectedUser.value;
const userProvider = provider.value;
const grantPrivilegesSql = userProvider?.grantPrivilegesSql;
if (!user || !userProvider || !grantPrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
if (usePrivilegeScopeEditor.value) {
// MySQL 表级授权:按所选库/表展开为多条 GRANT(仅追加,不回收该用户已有权限)
if (!privilegeAuthorizationsValid.value) return;
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases: privilegeAuthorizations.value, grantOption: grantOption.value }));
if (!privilegeAuthorizationsValid.value || !tableGrantDiffReady.value) return;
const databases = await prepareAuthorizationSelections(privilegeAuthorizations.value);
previewPlan(
buildGrantAuthorizationPlan({
provider: userProvider,
user,
databases,
grantOption: grantOption.value,
currentGrants: userProvider.authorizationModel === "mysql" ? undefined : currentTableGrants.value,
}),
);
return;
}
previewSql(
@@ -504,15 +632,15 @@ function previewGrant() {
);
}
function previewRevoke() {
async function previewRevoke() {
const user = selectedUser.value;
const userProvider = provider.value;
const revokePrivilegesSql = userProvider?.revokePrivilegesSql;
if (!user || !userProvider || !revokePrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
if (usePrivilegeScopeEditor.value) {
// 与授权保持同一套选择:按所选库/表展开为多条 REVOKE
if (!privilegeAuthorizationsValid.value) return;
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases: privilegeAuthorizations.value, revoke: true }), { danger: true });
if (!privilegeAuthorizationsValid.value || !tableGrantDiffReady.value) return;
const databases = await prepareAuthorizationSelections(privilegeAuthorizations.value);
previewPlan(buildGrantAuthorizationPlan({ provider: userProvider, user, databases, revoke: true, currentGrants: userProvider.authorizationModel === "mysql" ? undefined : currentTableGrants.value }), { danger: true });
return;
}
previewSql(
@@ -551,9 +679,14 @@ watch(
watch(
() => selectedUserKey.value,
() => {
tableGrantRequestId += 1;
grantsLoaded.value = false;
// 切换用户时清空上一位用户选择的授权范围,避免把权限误授给当前用户
privilegeAuthorizations.value = [];
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
currentTableGrantsLoading.value = false;
tableGrantLoadError.value = "";
void loadGrants();
},
);
@@ -561,17 +694,23 @@ watch(
watch(
() => props.connection.id,
() => {
tableGrantRequestId += 1;
users.value = [];
createDatabases.value = [];
createDatabasesLoadError.value = "";
createDatabaseAuthorizations.value = [];
privilegeAuthorizations.value = [];
currentTableGrants.value = [];
currentTableGrantsLoaded.value = false;
currentTableGrantsLoading.value = false;
tableGrantLoadError.value = "";
selectedUserKey.value = "";
grants.value = [];
grantsLoaded.value = false;
privilegeScope.value = provider.value?.defaultScope ?? "mysql";
resetPrivilegeDefaults(privilegeScope.value);
void loadUsers();
if (usePrivilegeScopeEditor.value) void loadDatabases();
if (usePrivilegeScopeEditor.value) void preparePrivilegeScopeEditor();
},
);
@@ -599,10 +738,17 @@ watch(
watch([privilegeDatabase, privilegeTable], syncPrivilegeSelectionFromGrants);
watch(
() => usePrivilegeScopeEditor.value,
async (enabled) => {
if (!enabled) return;
await preparePrivilegeScopeEditor();
},
);
onMounted(() => {
void loadUsers();
// 权限编辑面板的授权范围选择需要数据库列表,进入页面即预加载(仅 MySQL 表级授权场景)
if (usePrivilegeScopeEditor.value) void loadDatabases();
if (usePrivilegeScopeEditor.value) void preparePrivilegeScopeEditor();
});
</script>
@@ -721,7 +867,7 @@ onMounted(() => {
<aside v-if="canEditPrivileges" class="flex min-h-0 flex-col bg-muted/10">
<div class="border-b p-3">
<div class="text-xs font-semibold">{{ t("userAdmin.privilegeEditor") }}</div>
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t(usePrivilegeScopeEditor ? "userAdmin.privilegeAppendHint" : "userAdmin.privilegeHint") }}</div>
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t(usePrivilegeScopeEditor && provider?.authorizationModel === "mysql" ? "userAdmin.privilegeAppendHint" : "userAdmin.privilegeHint") }}</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-3">
<template v-if="isPostgres">
@@ -743,7 +889,14 @@ onMounted(() => {
<label class="mb-2 block text-xs font-medium">{{ t("userAdmin.memberRole") }}</label>
<Input v-model="privilegeRole" class="mb-3 h-8 text-xs" :placeholder="t('userAdmin.memberRole')" />
</template>
<AuthorizationScopeEditor v-else-if="usePrivilegeScopeEditor" v-model="privilegeAuthorizations" compact :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
<div v-else-if="usePrivilegeScopeEditor" class="flex h-full min-h-0 flex-col gap-2">
<AuthorizationScopeEditor v-model="privilegeAuthorizations" compact :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
<div v-if="currentTableGrantsLoading" class="flex items-center gap-2 text-[11px] text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("userAdmin.loadingGrants") }}
</div>
<p v-else-if="tableGrantLoadError" class="text-[11px] text-destructive">{{ tableGrantLoadError }}</p>
</div>
<template v-else>
<label class="mb-2 block text-xs font-medium">
{{ isPostgres && privilegeScope !== "database" ? t("userAdmin.schema") : t("userAdmin.database") }}
@@ -781,10 +934,10 @@ onMounted(() => {
</span>
</label>
<div class="flex shrink-0 items-center justify-end gap-2 border-t p-3">
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewRevoke">
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && (!privilegeAuthorizationsValid || !tableGrantDiffReady)" @click="previewRevoke">
{{ t("userAdmin.revoke") }}
</Button>
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewGrant">
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && (!privilegeAuthorizationsValid || !tableGrantDiffReady)" @click="previewGrant">
{{ t("userAdmin.grant") }}
</Button>
</div>
@@ -11,8 +11,11 @@ const mocks = vi.hoisted(() => ({
productionGuard: vi.fn(),
toast: vi.fn(),
listDatabases: vi.fn(),
listDorisCatalogs: vi.fn(),
listDorisCatalogDatabases: vi.fn(),
listTables: vi.fn(),
listSchemas: vi.fn(),
tabUiState: {} as Record<string, unknown>,
}));
function passthrough(tag: string): Component {
@@ -119,6 +122,8 @@ vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
executeMulti: mocks.executeMulti,
listDatabases: mocks.listDatabases,
listDorisCatalogs: mocks.listDorisCatalogs,
listDorisCatalogDatabases: mocks.listDorisCatalogDatabases,
listTables: mocks.listTables,
listSchemas: mocks.listSchemas,
}));
@@ -128,6 +133,9 @@ vi.mock("@/lib/database/productionExecutionGuard", () => ({
return options.execute();
},
}));
vi.mock("@/lib/tabs/tabUiState", () => ({
useTabUiState: () => ({ initialState: mocks.tabUiState, track: vi.fn(), update: vi.fn() }),
}));
import DatabaseUserAdmin from "@/components/admin/DatabaseUserAdmin.vue";
@@ -151,6 +159,26 @@ const nativeMysqlConnection: ConnectionConfig = {
port: 3306,
};
const postgresConnection: ConnectionConfig = {
...connection,
id: "postgres",
name: "PostgreSQL",
db_type: "postgres",
driver_profile: "postgres",
database: "app-db",
port: 5432,
};
const starrocksConnection: ConnectionConfig = {
...connection,
id: "starrocks",
name: "StarRocks",
db_type: "starrocks",
driver_profile: "starrocks",
database: "analytics",
port: 9030,
};
let app: ReturnType<typeof createApp> | undefined;
let root: HTMLDivElement | undefined;
@@ -159,6 +187,7 @@ afterEach(() => {
root?.remove();
app = undefined;
root = undefined;
mocks.tabUiState = {};
vi.clearAllMocks();
});
@@ -266,6 +295,140 @@ describe("DatabaseUserAdmin MySQL create-user table grants", () => {
});
});
describe("DatabaseUserAdmin PostgreSQL create-user table grants", () => {
it("loads schema-qualified tables and keeps same-named tables distinct", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["postgres", "LOGIN", "SUPERUSER"]] }).mockResolvedValueOnce({ columns: ["line"], rows: [["Role: postgres"]] });
mocks.listDatabases.mockResolvedValue([{ name: "app-db" }]);
mocks.listSchemas.mockResolvedValue(["sales", "audit", "pg_catalog"]);
mocks.listTables.mockImplementation(async (_connectionId, _database, schema) => [{ name: "orders", table_type: "BASE TABLE" }, ...(schema === "sales" ? [{ name: 'daily"rollup', table_type: "BASE TABLE" }] : [])]);
root = document.createElement("div");
document.body.append(root);
app = createApp(DatabaseUserAdmin, { connection: postgresConnection });
app.mount(root);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
findButton("userAdmin.newUser")?.click();
await vi.waitFor(() => expect(findDialogButton("app-db")).toBeDefined());
findDialogButton("app-db")?.click();
const password = root.querySelector<HTMLInputElement>('[data-password-input="true"]');
password!.value = "test-password";
password!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
findDialogButton("userAdmin.specificTables")?.click();
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalledTimes(2));
expect(mocks.listTables.mock.calls.map((call) => call[2])).toEqual(["sales", "audit"]);
expect(findDialogButton("sales.orders")).toBeDefined();
expect(findDialogButton("audit.orders")).toBeDefined();
findDialogButton("sales.orders")?.click();
findDialogButton("audit.orders")?.click();
findDialogButton('sales.daily"rollup')?.click();
await nextTick();
findDialogButton("userAdmin.previewSql")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain('GRANT SELECT ON TABLE "sales"."orders" TO "app_user";'));
expect(root?.textContent).toContain('GRANT SELECT ON TABLE "audit"."orders" TO "app_user";');
expect(root?.textContent).toContain('GRANT SELECT ON TABLE "sales"."daily""rollup" TO "app_user";');
expect(root?.textContent).not.toContain("ON ALL TABLES");
});
it("loads an existing schema-qualified grant and executes its revoke in the target database", async () => {
mocks.tabUiState = { privilegeScope: "table" };
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockImplementation(async (_connectionId, database, sql) => {
if (sql.includes('r.rolname AS "user"')) return { columns: ["user", "host", "plugin"], rows: [["reader", "LOGIN", ""]] };
if (sql.includes("FROM information_schema.table_privileges") && !sql.includes("UNION ALL")) return { columns: ["schema", "table", "privilege", "grant_option"], rows: [["sales", "orders", "SELECT", "NO"]] };
return { columns: ["line"], rows: [["Role: reader"]] };
});
mocks.listDatabases.mockResolvedValue([{ name: "app-db" }]);
mocks.listSchemas.mockResolvedValue(["sales"]);
mocks.listTables.mockResolvedValue([{ name: "orders", table_type: "BASE TABLE" }]);
root = document.createElement("div");
document.body.append(root);
app = createApp(DatabaseUserAdmin, { connection: postgresConnection });
app.mount(root);
await vi.waitFor(() => expect(findButton("userAdmin.revoke")?.disabled).toBe(false));
expect(root.textContent).toContain("userAdmin.selectedTableCount");
findButton("userAdmin.revoke")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain('REVOKE SELECT ON TABLE "sales"."orders" FROM "reader";'));
mocks.executeMulti.mockResolvedValue([]);
findButton("userAdmin.applySql")?.click();
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalled());
expect(mocks.executeMulti.mock.calls[0][1]).toBe("app-db");
expect(mocks.executeMulti.mock.calls[0][2]).toBe('REVOKE SELECT ON TABLE "sales"."orders" FROM "reader";');
});
});
describe("DatabaseUserAdmin StarRocks table grants", () => {
it("loads catalog-qualified tables for create and previews catalog-aware SQL", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["User"], rows: [["'root'@'%'"]] }).mockResolvedValueOnce({ columns: ["UserIdentity", "Catalog", "Grants"], rows: [["'root'@'%'", null, "GRANT 'root' TO USER 'root'@'%'"]] });
mocks.listDorisCatalogs.mockResolvedValue([
{ name: "default_catalog", catalog_type: "Internal", is_current: true },
{ name: "hive`catalog", catalog_type: "Hive", is_current: false },
]);
mocks.listDorisCatalogDatabases.mockResolvedValue([{ name: "analytics" }]);
mocks.listTables.mockResolvedValue([{ name: "event`log", table_type: "BASE TABLE" }]);
root = document.createElement("div");
document.body.append(root);
app = createApp(DatabaseUserAdmin, { connection: starrocksConnection });
app.mount(root);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
findButton("userAdmin.newUser")?.click();
await vi.waitFor(() => expect(findDialogButton("hive`catalog / analytics")).toBeDefined());
findDialogButton("hive`catalog / analytics")?.click();
const password = root.querySelector<HTMLInputElement>('[data-password-input="true"]');
password!.value = "test-password";
password!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
findDialogButton("userAdmin.specificTables")?.click();
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalled());
expect(mocks.listTables).toHaveBeenCalledWith("starrocks", "analytics", "", undefined, undefined, undefined, ["TABLE"], "hive`catalog");
await vi.waitFor(() => expect(findDialogButton("event`log")).toBeDefined());
findDialogButton("event`log")?.click();
await nextTick();
findDialogButton("userAdmin.previewSql")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("SET CATALOG `hive``catalog`;"));
expect(root?.textContent).toContain("GRANT SELECT ON TABLE `analytics`.`event``log` TO USER 'app_user'@'%';");
mocks.executeMulti.mockResolvedValue([]);
mocks.executeQuery.mockResolvedValue({ columns: ["User"], rows: [["'app_user'@'%'"]] });
findButton("userAdmin.applySql")?.click();
await vi.waitFor(() => expect(mocks.executeMulti).toHaveBeenCalledTimes(2));
expect(mocks.executeMulti.mock.calls[1][5]).toEqual(expect.objectContaining({ catalog: "hive`catalog" }));
});
it("restores a direct table grant and previews its revoke diff", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["User"], rows: [["'reader'@'%'"]] }).mockResolvedValueOnce({
columns: ["UserIdentity", "Catalog", "Grants"],
rows: [["'reader'@'%'", "default", "GRANT SELECT ON TABLE `analytics`.`events` TO USER 'reader'@'%'"]],
});
mocks.listDorisCatalogs.mockResolvedValue([{ name: "default_catalog", catalog_type: "Internal", is_current: true }]);
mocks.listDorisCatalogDatabases.mockResolvedValue([{ name: "analytics" }]);
root = document.createElement("div");
document.body.append(root);
app = createApp(DatabaseUserAdmin, { connection: starrocksConnection });
app.mount(root);
await vi.waitFor(() => expect(findButton("userAdmin.revoke")?.disabled).toBe(false));
expect(root.textContent).toContain("userAdmin.selectedTableCount");
findButton("userAdmin.revoke")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("REVOKE SELECT ON TABLE `analytics`.`events` FROM USER 'reader'@'%';"));
expect(root?.textContent).toContain("SET CATALOG `default_catalog`;");
});
});
describe("DatabaseUserAdmin MySQL privilege editing", () => {
it("previews one grant per selected table for the selected user", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { dorisUserAdminProvider, mysqlUserAdminProvider, postgresUserAdminProvider, starrocksUserAdminProvider } from "@/lib/database/databaseUserAdmin";
import { authorizationPlanSql, buildCreateDatabaseAuthorizationPlan, buildCreateUserAuthorizationPlan, buildGrantAuthorizationPlan, executeAuthorizationPlan } from "@/lib/database/databaseAuthorizationPlan";
import { authorizationPlanSql, buildCreateDatabaseAuthorizationPlan, buildCreateUserAuthorizationPlan, buildGrantAuthorizationPlan, databaseAuthorizationsFromTableGrants, executeAuthorizationPlan } from "@/lib/database/databaseAuthorizationPlan";
describe("database authorization plans", () => {
it("grants PostgreSQL presets across user schemas and future objects", () => {
@@ -79,6 +79,80 @@ describe("database authorization plans", () => {
expect(sql).not.toContain('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA "app" TO "reader";');
});
it("grants only selected PostgreSQL tables across schemas with quoted identifiers", () => {
const plan = buildCreateUserAuthorizationPlan({
provider: postgresUserAdminProvider,
principal: { user: 'report"reader', host: "LOGIN", password: "secret", canLogin: true },
accountType: "standard",
databases: [
{
database: "app-db",
preset: "readWrite",
schemas: ["public", "sales", "audit"],
tables: [
{ schema: "sales", name: "orders" },
{ schema: "audit", name: "orders" },
{ schema: 'odd"schema', name: 'daily"rollup' },
],
},
],
});
const sql = authorizationPlanSql(plan);
expect(sql).toContain('GRANT CONNECT ON DATABASE "app-db" TO "report""reader";');
expect(sql).toContain('GRANT USAGE ON SCHEMA "sales" TO "report""reader";');
expect(sql).toContain('GRANT SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON TABLE "sales"."orders" TO "report""reader";');
expect(sql).toContain('ON TABLE "audit"."orders"');
expect(sql).toContain('ON TABLE "odd""schema"."daily""rollup"');
expect(sql).not.toContain("ON ALL TABLES");
expect(sql).not.toContain("ALTER DEFAULT PRIVILEGES");
});
it("diffs PostgreSQL table grants and revokes only existing privileges", () => {
const selection = [{ database: "app_db", preset: "custom" as const, privileges: ["SELECT", "INSERT"], tables: [{ schema: "tenant", name: "orders" }] }];
const currentGrants = [{ database: "app_db", schema: "tenant", table: "orders", privilege: "SELECT", grantOption: false }];
const grant = buildGrantAuthorizationPlan({ provider: postgresUserAdminProvider, user: { user: "reader", host: "LOGIN" }, databases: selection, currentGrants });
const revoke = buildGrantAuthorizationPlan({ provider: postgresUserAdminProvider, user: { user: "reader", host: "LOGIN" }, databases: selection, currentGrants, revoke: true });
expect(grant.steps).toHaveLength(1);
expect(grant.steps[0]).toMatchObject({ database: "app_db", targetDatabase: "app_db", targetSchema: "tenant", targetTable: "orders" });
expect(grant.steps[0].sql).toBe('GRANT INSERT ON TABLE "tenant"."orders" TO "reader";');
expect(revoke.steps).toHaveLength(1);
expect(revoke.steps[0].sql).toBe('REVOKE SELECT ON TABLE "tenant"."orders" FROM "reader";');
});
it("revokes existing PostgreSQL table privileges across a selected schema", () => {
const plan = buildGrantAuthorizationPlan({
provider: postgresUserAdminProvider,
user: { user: "reader", host: "LOGIN" },
databases: [{ database: "app_db", preset: "custom", privileges: ["SELECT"], schemas: ["sales"], tables: undefined }],
currentGrants: [{ database: "app_db", schema: "sales", table: "orders", privilege: "SELECT", grantOption: false }],
revoke: true,
});
expect(plan.steps).toHaveLength(1);
expect(plan.steps[0].sql).toBe('REVOKE SELECT ON ALL TABLES IN SCHEMA "sales" FROM "reader";');
});
it("keeps same-named PostgreSQL tables in distinct schemas when restoring grants", () => {
const loaded = databaseAuthorizationsFromTableGrants(postgresUserAdminProvider, [
{ database: "app_db", schema: "sales", table: "orders", privilege: "SELECT", grantOption: false },
{ database: "app_db", schema: "audit", table: "orders", privilege: "SELECT", grantOption: false },
]);
expect(loaded.selections).toEqual([
{
database: "app_db",
preset: "custom",
privileges: ["SELECT"],
tables: [
{ name: "orders", schema: "sales" },
{ name: "orders", schema: "audit" },
],
},
]);
});
it("keeps MySQL database grants scoped to the selected database", () => {
const plan = buildCreateDatabaseAuthorizationPlan({
provider: mysqlUserAdminProvider,
@@ -126,7 +200,7 @@ describe("database authorization plans", () => {
provider: mysqlUserAdminProvider,
principal: { user: "table_reader", host: "%", password: "secret" },
accountType: "standard",
databases: [{ database: "app_db", preset: "readWrite", tables: ["orders", "audit`log", "orders"] }],
databases: [{ database: "app_db", preset: "readWrite", tables: [{ name: "orders" }, { name: "audit`log" }, { name: "orders" }] }],
});
const sql = authorizationPlanSql(plan);
const grants = plan.steps.filter((step) => step.operation === "grantDatabase");
@@ -152,6 +226,61 @@ describe("database authorization plans", () => {
expect(authorizationPlanSql(plan)).toContain("GRANT SELECT, SHOW VIEW ON `app_db`.* TO 'db_reader'@'%';");
});
it("builds StarRocks grants in the selected catalog with StarRocks privileges", () => {
const plan = buildCreateUserAuthorizationPlan({
provider: starrocksUserAdminProvider,
principal: { user: "table_reader", host: "%", password: "secret" },
accountType: "standard",
databases: [
{
catalog: "ice`berg",
database: "sales-db",
preset: "readWrite",
tables: [{ name: "orders" }, { name: "audit`log" }],
},
],
});
const grants = plan.steps.filter((step) => step.operation === "grantDatabase");
expect(grants).toHaveLength(2);
expect(grants[0]).toMatchObject({ database: "sales-db", targetCatalog: "ice`berg", targetDatabase: "sales-db", targetTable: "orders" });
expect(grants[0].sql).toBe("SET CATALOG `ice``berg`;\nGRANT SELECT, INSERT, UPDATE, DELETE, ALTER, DROP, EXPORT ON TABLE `sales-db`.`orders` TO USER 'table_reader'@'%';");
expect(grants[1].sql).toContain("ON TABLE `sales-db`.`audit``log`");
expect(authorizationPlanSql(plan)).not.toContain("SHOW VIEW");
expect(authorizationPlanSql(plan)).not.toContain("TRIGGER");
});
it("does not emit a fallback grant for an empty PostgreSQL or StarRocks table selection", () => {
const postgres = buildCreateUserAuthorizationPlan({
provider: postgresUserAdminProvider,
principal: { user: "reader", host: "LOGIN", password: "secret" },
accountType: "standard",
databases: [{ database: "app_db", preset: "readOnly", tables: [] }],
});
const starrocks = buildCreateUserAuthorizationPlan({
provider: starrocksUserAdminProvider,
principal: { user: "reader", host: "%", password: "secret" },
accountType: "standard",
databases: [{ catalog: "default_catalog", database: "app_db", preset: "readOnly", tables: [] }],
});
expect(postgres.steps.map((step) => step.operation)).toEqual(["createUser"]);
expect(starrocks.steps.map((step) => step.operation)).toEqual(["createUser"]);
expect(authorizationPlanSql(postgres)).not.toContain("ON TABLE");
expect(authorizationPlanSql(starrocks)).not.toContain("GRANT SELECT");
});
it("diffs StarRocks table grants before grant and revoke", () => {
const databases = [{ catalog: "hive", database: "analytics", preset: "custom" as const, privileges: ["SELECT", "INSERT"], tables: [{ name: "events" }] }];
const currentGrants = [{ catalog: "hive", database: "analytics", table: "events", privilege: "SELECT", grantOption: false }];
const user = { user: "reporter", host: "10.%" };
const grant = buildGrantAuthorizationPlan({ provider: starrocksUserAdminProvider, user, databases, currentGrants });
const revoke = buildGrantAuthorizationPlan({ provider: starrocksUserAdminProvider, user, databases, currentGrants, revoke: true });
expect(grant.steps[0].sql).toBe("SET CATALOG `hive`;\nGRANT INSERT ON TABLE `analytics`.`events` TO USER 'reporter'@'10.%';");
expect(revoke.steps[0].sql).toBe("SET CATALOG `hive`;\nREVOKE SELECT ON TABLE `analytics`.`events` FROM USER 'reporter'@'10.%';");
});
it("reports PostgreSQL object grants independently", async () => {
const plan = buildCreateDatabaseAuthorizationPlan({
provider: postgresUserAdminProvider,
@@ -189,7 +318,7 @@ describe("database authorization plans", () => {
const plan = buildGrantAuthorizationPlan({
provider: mysqlUserAdminProvider,
user: { user: "app_user", host: "%" },
databases: [{ database: "app_db", preset: "readOnly", tables: ["orders", "audit_log"] }],
databases: [{ database: "app_db", preset: "readOnly", tables: [{ name: "orders" }, { name: "audit_log" }] }],
grantOption: true,
});
const sql = authorizationPlanSql(plan);
@@ -215,7 +344,7 @@ describe("database authorization plans", () => {
const plan = buildGrantAuthorizationPlan({
provider: mysqlUserAdminProvider,
user: { user: "app_user", host: "%" },
databases: [{ database: "app_db", preset: "readWrite", tables: ["orders"] }],
databases: [{ database: "app_db", preset: "readWrite", tables: [{ name: "orders" }] }],
grantOption: true,
revoke: true,
});
@@ -226,13 +355,10 @@ describe("database authorization plans", () => {
expect(authorizationPlanSql(plan)).not.toContain("GRANT OPTION");
});
it("leaves Doris and StarRocks to their own privilege names", () => {
for (const provider of [dorisUserAdminProvider, starrocksUserAdminProvider]) {
// 两者都能生成 GRANT SQL,但权限名与 MySQL 预设不同,因此不应由本计划构造语句
expect(provider.grantPrivilegesSql).toBeTypeOf("function");
const plan = buildGrantAuthorizationPlan({ provider, user: { user: "app_user", host: "%" }, databases: [{ database: "app_db", preset: "readOnly" }] });
it("keeps unsupported Doris table grants behind its capability", () => {
expect(dorisUserAdminProvider.grantPrivilegesSql).toBeTypeOf("function");
const plan = buildGrantAuthorizationPlan({ provider: dorisUserAdminProvider, user: { user: "app_user", host: "%" }, databases: [{ database: "app_db", preset: "readOnly" }] });
expect(plan.steps).toEqual([]);
}
expect(plan.steps).toEqual([]);
});
});
@@ -10,8 +10,11 @@ import {
kingbaseUserAdminProvider,
mysqlPrivilegeSelectionFromGrants,
nativeMysqlUserAdminProvider,
postgresTableGrantsResult,
postgresTableGrantsSql,
postgresUserAdminProvider,
resolveDatabaseUserAdminProviderForConnection,
starrocksTableGrantsResult,
} from "@/lib/database/databaseUserAdmin";
import type { ConnectionConfig, QueryResult } from "@/types/database";
@@ -143,11 +146,36 @@ describe("database user admin providers", () => {
expect(sql).not.toContain(" || ");
});
it("keeps native GaussDB, PostgreSQL, and Kingbase providers unchanged", () => {
expect(resolveDatabaseUserAdminProviderForConnection(connection("gaussdb"))).toBe(postgresUserAdminProvider);
it("limits PostgreSQL table selection to native PostgreSQL", () => {
const gaussdb = resolveDatabaseUserAdminProviderForConnection(connection("gaussdb"));
expect(gaussdb).not.toBe(postgresUserAdminProvider);
expect(gaussdb?.supportsTableGrantsOnCreate).toBeUndefined();
expect(resolveDatabaseUserAdminProviderForConnection(connection("postgres"))).toBe(postgresUserAdminProvider);
expect(resolveDatabaseUserAdminProviderForConnection(connection("kingbase"))).toBe(kingbaseUserAdminProvider);
expect(getDatabaseUserAdminProvider("gaussdb")).toBe(postgresUserAdminProvider);
expect(postgresUserAdminProvider.supportsTableGrantsOnCreate).toBe(true);
expect(getDatabaseUserAdminProvider("gaussdb")?.supportsTableGrantsOnCreate).toBeUndefined();
});
it("loads direct PostgreSQL table grants with database and schema identity", () => {
const sql = postgresTableGrantsSql({ user: "role'o", host: "LOGIN" });
const grants = postgresTableGrantsResult(
result(
["schema", "table", "privilege", "grant_option"],
[
["sales", "orders", "SELECT", "NO"],
['odd"schema', 'daily"rollup', "UPDATE", "YES"],
],
),
{ database: "app-db" },
);
expect(sql).toContain("FROM information_schema.table_privileges");
expect(sql).toContain("WHERE grantee = 'role''o'");
expect(grants).toEqual([
{ database: "app-db", schema: "sales", table: "orders", privilege: "SELECT", grantOption: false },
{ database: "app-db", schema: 'odd"schema', table: 'daily"rollup', privilege: "UPDATE", grantOption: true },
]);
});
it("uses Doris 2.x user and privilege syntax", () => {
@@ -188,12 +216,33 @@ describe("database user admin providers", () => {
expect(postgresProvider?.privilegeSelectionFromGrants).toBeUndefined();
expect(dorisProvider?.privilegeSelectionFromGrants).toBeUndefined();
expect(starrocksProvider?.privilegeSelectionFromGrants).toBeUndefined();
expect(postgresProvider?.parseTableGrants).toBe(postgresTableGrantsResult);
expect(starrocksProvider?.parseTableGrants).toBe(starrocksTableGrantsResult);
expect(mysqlProvider?.defaultPrivilegesForScope?.("mysql")).toEqual(["SELECT"]);
expect(postgresProvider?.defaultPrivilegesForScope?.("database")).toEqual(["CONNECT"]);
expect(dorisProvider?.defaultPrivilegesForScope?.("table")).toEqual(["SELECT_PRIV"]);
expect(starrocksProvider?.defaultPrivilegesForScope?.("table")).toEqual(["SELECT"]);
});
it("parses StarRocks table grants with catalog and escaped identifiers", () => {
const grants = starrocksTableGrantsResult(
result(
["UserIdentity", "Catalog", "Grants"],
[
["'reader'@'%'", "default", "GRANT SELECT ON ALL TABLES IN DATABASE `sales.db` TO USER 'reader'@'%'"],
["'reader'@'%'", "ice`berg", "GRANT INSERT, UPDATE ON TABLE `sales.db`.`daily``rollup` TO USER 'reader'@'%' WITH GRANT OPTION"],
["'reader'@'%'", null, "GRANT analyst TO USER 'reader'@'%'"],
],
),
);
expect(grants).toEqual([
{ catalog: "default_catalog", database: "sales.db", table: "*", privilege: "SELECT", grantOption: false },
{ catalog: "ice`berg", database: "sales.db", table: "daily`rollup", privilege: "INSERT", grantOption: true },
{ catalog: "ice`berg", database: "sales.db", table: "daily`rollup", privilege: "UPDATE", grantOption: true },
]);
});
it("uses sys_catalog for Kingbase role metadata", () => {
const provider = getDatabaseUserAdminProvider("kingbase");
@@ -1,5 +1,5 @@
import type { QueryResult } from "@/types/database";
import { mysqlUserAccount, quoteMySqlIdentifier, quotePostgresIdentifier, type CreatePrincipalInput, type DatabaseUserAdminProvider, type DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
import { mysqlUserAccount, normalizeStarrocksCatalog, POSTGRES_TABLE_PRIVILEGES, quoteMySqlIdentifier, quotePostgresIdentifier, type CreatePrincipalInput, type DatabaseTablePrivilegeGrant, type DatabaseUserAdminProvider, type DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
export type AuthorizationAccountType = "standard" | "admin";
export type AuthorizationPreset = "readWrite" | "readOnly" | "ddl" | "dml" | "custom";
@@ -8,11 +8,27 @@ export type AuthorizationStepOperation = "createUser" | "grantAdmin" | "createDa
export type AuthorizationObjectScope = "schemas" | "tables" | "sequences" | "functions";
export interface DatabaseAuthorizationSelection {
catalog?: string;
database: string;
preset: AuthorizationPreset;
privileges?: string[];
schemas?: string[];
tables?: string[];
tables?: AuthorizationTableSelection[];
}
export interface AuthorizationTableSelection {
name: string;
schema?: string;
}
export interface AuthorizationDatabaseOption {
catalog?: string;
database: string;
}
export interface LoadedDatabaseAuthorizations {
selections: DatabaseAuthorizationSelection[];
grantOption: boolean;
}
export interface AuthorizationPlanStep {
@@ -23,7 +39,9 @@ export interface AuthorizationPlanStep {
dependsOn?: string[];
operation: AuthorizationStepOperation;
subject?: string;
targetCatalog?: string;
targetDatabase?: string;
targetSchema?: string;
targetTable?: string;
objectScope?: AuthorizationObjectScope;
schema?: string;
@@ -64,6 +82,7 @@ const MYSQL_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]> =
};
const MYSQL_TABLE_PRIVILEGES = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "SHOW VIEW", "CREATE VIEW", "TRIGGER"]);
const POSTGRES_TABLE_PRIVILEGE_SET = new Set<string>(POSTGRES_TABLE_PRIVILEGES);
const POSTGRES_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]> = {
readOnly: ["SELECT"],
@@ -72,21 +91,78 @@ const POSTGRES_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]>
readWrite: ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "EXECUTE", "CREATE", "TEMPORARY"],
};
const STARROCKS_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]> = {
readOnly: ["SELECT"],
dml: ["SELECT", "INSERT", "UPDATE", "DELETE"],
ddl: ["ALTER", "DROP"],
readWrite: ["SELECT", "INSERT", "UPDATE", "DELETE", "ALTER", "DROP", "EXPORT"],
};
export function authorizationPresetPrivileges(provider: DatabaseUserAdminProvider, preset: AuthorizationPreset, custom: string[] = [], targetScope: AuthorizationTargetScope = "database"): string[] {
const privileges = preset === "custom" ? uniquePrivileges(custom) : [...(provider.dialect === "mysql" ? MYSQL_PRESETS[preset] : POSTGRES_PRESETS[preset])];
return provider.dialect === "mysql" && targetScope === "table" ? privileges.filter((privilege) => MYSQL_TABLE_PRIVILEGES.has(privilege)) : privileges;
const presetPrivileges = provider.authorizationModel === "starrocks" ? STARROCKS_PRESETS : provider.dialect === "mysql" ? MYSQL_PRESETS : POSTGRES_PRESETS;
const privileges = preset === "custom" ? uniquePrivileges(custom) : [...presetPrivileges[preset]];
if (provider.authorizationModel === "starrocks" && privileges.includes("ALL")) return ["ALL"];
if (targetScope !== "table") return privileges;
if (provider.authorizationModel === "mysql") return privileges.filter((privilege) => MYSQL_TABLE_PRIVILEGES.has(privilege));
const tablePrivileges = new Set(provider.privilegesForScope?.("table") ?? []);
return privileges.filter((privilege) => tablePrivileges.has(privilege));
}
export function authorizationPrivileges(provider: DatabaseUserAdminProvider, targetScope: AuthorizationTargetScope = "database"): string[] {
const privilegesForScope = provider.privilegesForScope;
if (!privilegesForScope) return [];
if (provider.authorizationModel === "starrocks") return Array.from(privilegesForScope("table"));
if (provider.dialect === "mysql") {
const privileges = Array.from(privilegesForScope("mysql"));
return targetScope === "table" ? privileges.filter((privilege) => MYSQL_TABLE_PRIVILEGES.has(privilege)) : privileges;
return provider.authorizationModel === "mysql" && targetScope === "table" ? privileges.filter((privilege) => MYSQL_TABLE_PRIVILEGES.has(privilege)) : privileges;
}
if (targetScope === "table") return Array.from(privilegesForScope("table"));
return uniquePrivileges([...privilegesForScope("database"), ...privilegesForScope("schema"), ...privilegesForScope("table"), "EXECUTE", "USAGE", "UPDATE"]);
}
export function databaseAuthorizationsFromTableGrants(provider: DatabaseUserAdminProvider, grants: readonly DatabaseTablePrivilegeGrant[]): LoadedDatabaseAuthorizations {
const available = authorizationPrivileges(provider, "table");
const allowed = new Set(available);
const byDatabase = new Map<string, DatabaseTablePrivilegeGrant[]>();
for (const grant of grants) {
if (!grant.database.trim() || grant.database === "*" || !grant.table.trim() || !allowed.has(grant.privilege)) continue;
const key = JSON.stringify([normalizeStarrocksCatalog(grant.catalog ?? "") ?? "", grant.database]);
const entries = byDatabase.get(key) ?? [];
entries.push(grant);
byDatabase.set(key, entries);
}
const selections: DatabaseAuthorizationSelection[] = [];
let everyGrantable = true;
for (const entries of byDatabase.values()) {
const wildcardEntries = entries.filter((grant) => grant.table === "*");
const selectedEntries = wildcardEntries.length > 0 ? wildcardEntries : entries;
const byTable = new Map<string, DatabaseTablePrivilegeGrant[]>();
for (const entry of selectedEntries) {
const key = JSON.stringify([entry.schema ?? "", entry.table]);
const tableGrants = byTable.get(key) ?? [];
tableGrants.push(entry);
byTable.set(key, tableGrants);
}
const tableGrants = Array.from(byTable.values());
if (tableGrants.length === 0) continue;
const allOnEveryTarget = tableGrants.every((targetGrants) => targetGrants.some((grant) => grant.privilege === "ALL"));
const commonPrivileges = allOnEveryTarget ? ["ALL"] : available.filter((privilege) => privilege !== "ALL" && tableGrants.every((targetGrants) => targetGrants.some((grant) => grant.privilege === privilege || grant.privilege === "ALL")));
if (commonPrivileges.length === 0) continue;
everyGrantable &&= tableGrants.every((targetGrants) => commonPrivileges.every((privilege) => targetGrants.some((grant) => (grant.privilege === privilege || grant.privilege === "ALL") && grant.grantOption)));
const first = selectedEntries[0];
const schemas = Array.from(new Set(wildcardEntries.flatMap((grant) => (grant.schema ? [grant.schema] : []))));
selections.push({
...(first.catalog ? { catalog: normalizeStarrocksCatalog(first.catalog) } : {}),
database: first.database,
preset: "custom",
privileges: commonPrivileges,
...(wildcardEntries.length > 0 ? { schemas } : { tables: Array.from(byTable.values(), ([grant]) => ({ name: grant.table, ...(grant.schema ? { schema: grant.schema } : {}) })) }),
});
}
return { selections, grantOption: selections.length > 0 && everyGrantable };
}
export function buildCreateUserAuthorizationPlan(input: CreateUserAuthorizationPlanInput): AuthorizationPlan {
const createStepId = "create-user";
// Connection-specific providers expose only the admin operations supported by that server.
@@ -124,27 +200,51 @@ export function buildCreateUserAuthorizationPlan(input: CreateUserAuthorizationP
for (const selection of input.databases) {
const database = selection.database.trim();
if (!database) continue;
if (input.provider.dialect === "mysql") {
const targetScope = input.provider.supportsTableGrantsOnCreate && selection.tables !== undefined ? "table" : "database";
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, targetScope);
const tables = targetScope === "database" ? ["*"] : uniqueNames(selection.tables ?? []);
if (selection.tables !== undefined && uniqueTables(selection.tables).length === 0) continue;
if (input.provider.authorizationModel === "starrocks") {
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, "table");
if (privileges.length === 0) continue;
const tables = selection.tables === undefined ? [{ name: "*" }] : uniqueTables(selection.tables);
for (const table of tables) {
const targetTable = table.name === "*" ? undefined : table.name;
steps.push({
id: `grant-${steps.length}`,
label: `grant ${input.provider.label(identity)} access to ${table === "*" ? database : `${database}.${table}`}`,
database: "",
sql: input.provider.grantPrivilegesSql({ user: identity, privileges, database, table, scope: "mysql" }),
label: `grant ${input.provider.label(identity)} access to ${authorizationTargetLabel(selection.catalog, database, undefined, targetTable)}`,
database,
sql: input.provider.grantPrivilegesSql({ user: identity, privileges, catalog: selection.catalog, database, table: table.name, scope: "table" }),
dependsOn: [createStepId],
operation: "grantDatabase",
subject: input.provider.label(identity),
targetCatalog: selection.catalog,
targetDatabase: database,
targetTable: table === "*" ? undefined : table,
targetTable,
});
}
continue;
}
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges);
steps.push(...postgresDatabaseAuthorizationSteps(identity, database, privileges, selection.schemas ?? ["public"], createStepId, steps.length));
if (input.provider.dialect === "mysql") {
const targetScope = input.provider.supportsTableGrantsOnCreate && selection.tables !== undefined ? "table" : "database";
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, targetScope);
const tables = targetScope === "database" ? [{ name: "*" }] : uniqueTables(selection.tables ?? []);
for (const table of tables) {
const targetTable = table.name === "*" ? undefined : table.name;
steps.push({
id: `grant-${steps.length}`,
label: `grant ${input.provider.label(identity)} access to ${authorizationTargetLabel(undefined, database, undefined, targetTable)}`,
database: "",
sql: input.provider.grantPrivilegesSql({ user: identity, privileges, database, table: table.name, scope: "mysql" }),
dependsOn: [createStepId],
operation: "grantDatabase",
subject: input.provider.label(identity),
targetDatabase: database,
targetTable,
});
}
continue;
}
const targetScope = input.provider.authorizationModel === "postgres" && selection.tables !== undefined ? "table" : "database";
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, targetScope);
steps.push(...postgresDatabaseAuthorizationSteps(identity, database, privileges, selection.schemas ?? ["public"], createStepId, steps.length, selection.tables));
}
return { steps };
}
@@ -159,39 +259,67 @@ export interface GrantAuthorizationPlanInput {
grantOption?: boolean;
/** 为 true 时生成 REVOKE,默认生成 GRANT */
revoke?: boolean;
/** 已加载的直接表授权;提供后仅生成实际有变化的权限语句 */
currentGrants?: readonly DatabaseTablePrivilegeGrant[];
}
/**
* 为“已存在的用户”构造授权计划:把多库/多表选择展开为多条独立的 GRANT / REVOKE。
* 与 buildCreateUserAuthorizationPlan 的差异:
* 1. 不包含创建用户步骤,因此各步骤之间没有依赖关系;
* 2. 采用追加式语义,只对勾选的库/表生成语句,不做差异比对,也不会回收用户已有的多余权限;
* 3. 目前仅用于 MySQL 表级/库级授权,其它方言返回空计划,由调用方沿用原有的单条 SQL 逻辑。
* 2. MySQL 保持原有追加式语义;PostgreSQL 与 StarRocks 在已加载直接授权时按权限差异生成语句;
* 3. PostgreSQL 的执行数据库、StarRocks 的执行 catalog 都保存在步骤元数据中,不能套用 MySQL 上下文。
*/
export function buildGrantAuthorizationPlan(input: GrantAuthorizationPlanInput): AuthorizationPlan {
const changePrivilegesSql = input.revoke ? input.provider.revokePrivilegesSql : input.provider.grantPrivilegesSql;
if (!changePrivilegesSql) return { steps: [] };
// 以 provider 的库/表级授权能力作为判据:Doris / StarRocks 的 dialect 同为 "mysql",
// 但它们的权限名与 MySQL 预设不同(SELECT_PRIV 等),仅比较 dialect 会生成无效语句。
if (!input.provider.supportsTableGrantsOnCreate) return { steps: [] };
const steps: AuthorizationPlanStep[] = [];
for (const selection of input.databases) {
const database = selection.database.trim();
if (!database) continue;
// 指定了表则按表级生效,否则作用于该库下的全部表
const targetScope = input.provider.supportsTableGrantsOnCreate && selection.tables !== undefined ? "table" : "database";
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, targetScope);
if (privileges.length === 0) continue;
const tables = targetScope === "database" ? ["*"] : uniqueNames(selection.tables ?? []);
const model = input.provider.authorizationModel;
const targetScope = selection.tables !== undefined || model === "postgres" || model === "starrocks" ? "table" : "database";
const desiredPrivileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, targetScope);
if (desiredPrivileges.length === 0) continue;
if (model === "postgres") {
const targets = selection.tables === undefined ? uniqueSchemas(selection.schemas ?? []).map((schema) => ({ name: "*", schema })) : uniqueTables(selection.tables);
for (const target of targets) {
const schema = target.schema?.trim();
if (!schema) continue;
const privileges = changedPrivileges(input, { database, schema, table: target.name }, desiredPrivileges);
if (privileges.length === 0) continue;
const targetTable = target.name === "*" ? undefined : target.name;
steps.push({
id: `change-${steps.length}`,
label: `${input.revoke ? "revoke" : "grant"} ${input.provider.label(input.user)} ${authorizationTargetLabel(undefined, database, schema, targetTable)}`,
database,
sql: changePrivilegesSql({ user: input.user, privileges, database: schema, table: target.name, grantOption: input.grantOption, scope: "table" }),
operation: input.revoke ? "revokePrivileges" : "grantDatabase",
subject: input.provider.label(input.user),
targetDatabase: database,
targetSchema: schema,
targetTable,
});
}
continue;
}
const tables = selection.tables === undefined ? [{ name: "*" }] : uniqueTables(selection.tables);
for (const table of tables) {
const targetTable = table === "*" ? undefined : table;
const targetTable = table.name === "*" ? undefined : table.name;
const privileges = model === "starrocks" ? changedPrivileges(input, { catalog: selection.catalog, database, table: table.name }, desiredPrivileges) : desiredPrivileges;
if (privileges.length === 0) continue;
const scope = model === "mysql" ? "mysql" : "table";
steps.push({
id: `change-${steps.length}`,
label: `${input.revoke ? "revoke" : "grant"} ${input.provider.label(input.user)} ${targetTable ? `${database}.${targetTable}` : database}`,
database: "",
sql: changePrivilegesSql({ user: input.user, privileges, database, table, grantOption: input.grantOption, scope: "mysql" }),
label: `${input.revoke ? "revoke" : "grant"} ${input.provider.label(input.user)} ${authorizationTargetLabel(selection.catalog, database, undefined, targetTable)}`,
database: model === "starrocks" ? database : "",
sql: changePrivilegesSql({ user: input.user, privileges, catalog: selection.catalog, database, table: table.name, grantOption: input.grantOption, scope }),
operation: input.revoke ? "revokePrivileges" : "grantDatabase",
subject: input.provider.label(input.user),
targetCatalog: selection.catalog,
targetDatabase: database,
targetTable,
});
@@ -307,7 +435,7 @@ function adminPrincipalGrantSql(provider: DatabaseUserAdminProvider, principal:
return `ALTER ROLE ${quotePostgresIdentifier(principal.user)} SUPERUSER CREATEDB CREATEROLE;`;
}
function postgresDatabaseAuthorizationSteps(user: DatabaseUserIdentity, databaseName: string, privileges: string[], schemaNames: string[], createStepId: string, index: number): AuthorizationPlanStep[] {
function postgresDatabaseAuthorizationSteps(user: DatabaseUserIdentity, databaseName: string, privileges: string[], schemaNames: string[], createStepId: string, index: number, selectedTables?: AuthorizationTableSelection[]): AuthorizationPlanStep[] {
const role = quotePostgresIdentifier(user.user);
const database = quotePostgresIdentifier(databaseName);
const privilegeSet = new Set(uniquePrivileges(privileges));
@@ -326,6 +454,43 @@ function postgresDatabaseAuthorizationSteps(user: DatabaseUserIdentity, database
},
];
if (selectedTables !== undefined) {
const tablePrivileges = [...privilegeSet].filter((privilege) => POSTGRES_TABLE_PRIVILEGE_SET.has(privilege));
const tables = uniqueTables(selectedTables).filter((table) => table.schema?.trim());
const schemas = uniqueSchemas(tables.flatMap((table) => (table.schema ? [table.schema] : [])));
for (const [schemaIndex, schemaName] of schemas.entries()) {
steps.push(
authorizationObjectStep({
id: `grant-schema-${index}-${schemaIndex}`,
user,
databaseName,
schema: schemaName,
objectScope: "schemas",
sql: `GRANT USAGE ON SCHEMA ${quotePostgresIdentifier(schemaName)} TO ${role};`,
dependsOn: [createStepId, databaseStepId],
}),
);
}
if (tablePrivileges.length > 0) {
for (const [tableIndex, table] of tables.entries()) {
const schemaName = table.schema!.trim();
steps.push(
authorizationObjectStep({
id: `grant-table-${index}-${tableIndex}`,
user,
databaseName,
schema: schemaName,
objectScope: "tables",
sql: `GRANT ${tablePrivileges.join(", ")} ON TABLE ${quotePostgresIdentifier(schemaName)}.${quotePostgresIdentifier(table.name)} TO ${role};`,
dependsOn: [createStepId, databaseStepId],
targetTable: table.name,
}),
);
}
}
return steps;
}
const schemas = uniqueSchemas(schemaNames);
if (schemas.length > 0) {
const tablePrivileges = [...privilegeSet].filter((privilege) => ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER"].includes(privilege));
@@ -408,6 +573,7 @@ interface AuthorizationObjectStepInput {
sql: string;
dependsOn: string[];
schema?: string;
targetTable?: string;
}
function authorizationObjectStep(input: AuthorizationObjectStepInput): AuthorizationPlanStep {
@@ -420,8 +586,10 @@ function authorizationObjectStep(input: AuthorizationObjectStepInput): Authoriza
operation: "grantCurrentObjects",
subject: input.user.user,
targetDatabase: input.databaseName,
targetSchema: input.schema,
objectScope: input.objectScope,
schema: input.schema,
targetTable: input.targetTable,
};
}
@@ -483,8 +651,60 @@ function uniquePrivileges(privileges: string[]): string[] {
return Array.from(new Set(privileges.map((privilege) => privilege.trim().toUpperCase()).filter(Boolean)));
}
function uniqueNames(names: string[]): string[] {
return Array.from(new Set(names.map((name) => name.trim()).filter(Boolean)));
function uniqueTables(tables: readonly AuthorizationTableSelection[]): AuthorizationTableSelection[] {
const seen = new Set<string>();
const unique: AuthorizationTableSelection[] = [];
for (const table of tables) {
const name = table.name.trim();
const schema = table.schema?.trim() || undefined;
if (!name) continue;
const key = JSON.stringify([schema ?? "", name]);
if (seen.has(key)) continue;
seen.add(key);
unique.push({ name, ...(schema ? { schema } : {}) });
}
return unique;
}
interface AuthorizationGrantTarget {
catalog?: string;
database: string;
schema?: string;
table: string;
}
function changedPrivileges(input: GrantAuthorizationPlanInput, target: AuthorizationGrantTarget, desiredPrivileges: string[]): string[] {
if (input.currentGrants === undefined) return desiredPrivileges;
const exactGrants = input.currentGrants.filter((grant) => tableGrantMatchesTarget(grant, target, false) || (input.revoke && input.provider.authorizationModel === "postgres" && target.table === "*" && postgresGrantIsInTargetSchema(grant, target)));
if (input.revoke) {
if (desiredPrivileges.includes("ALL")) return exactGrants.length > 0 ? ["ALL"] : [];
return desiredPrivileges.filter((privilege) => exactGrants.some((grant) => grant.privilege === privilege || grant.privilege === "ALL"));
}
const effectiveGrants = input.currentGrants.filter((grant) => tableGrantMatchesTarget(grant, target, true));
return desiredPrivileges.filter((privilege) => {
const current = effectiveGrants.filter((grant) => grant.privilege === privilege || grant.privilege === "ALL");
return current.length === 0 || (!!input.grantOption && current.every((grant) => !grant.grantOption));
});
}
function postgresGrantIsInTargetSchema(grant: DatabaseTablePrivilegeGrant, target: AuthorizationGrantTarget): boolean {
return grant.catalog === undefined && grant.database === target.database && grant.schema === target.schema;
}
function tableGrantMatchesTarget(grant: DatabaseTablePrivilegeGrant, target: AuthorizationGrantTarget, allowBroader: boolean): boolean {
const targetCatalog = normalizeStarrocksCatalog(target.catalog ?? "") ?? "default_catalog";
const grantCatalog = normalizeStarrocksCatalog(grant.catalog ?? "") ?? "default_catalog";
if (target.catalog !== undefined || grant.catalog !== undefined) {
if (targetCatalog !== grantCatalog) return false;
}
if (grant.schema !== target.schema) return false;
if (grant.database !== target.database && !(allowBroader && grant.database === "*")) return false;
return grant.table === target.table || (allowBroader && grant.table === "*");
}
function authorizationTargetLabel(catalog: string | undefined, database: string, schema: string | undefined, table: string | undefined): string {
return [catalog, database, schema, table].filter((part): part is string => !!part).join(".");
}
function queryResultMessage(result: QueryResult): string {
@@ -4,6 +4,7 @@ import { effectiveDatabaseTypeForConnection, gaussdbConnectionMode } from "@/lib
export type UserAdminDialect = "mysql" | "postgres";
export type PrivilegeScope = "mysql" | "database" | "schema" | "table" | "role";
export type AuthorizationModel = "mysql" | "postgres" | "starrocks";
export interface DatabaseUserIdentity {
user: string;
@@ -19,6 +20,7 @@ export interface CreatePrincipalInput extends DatabaseUserIdentity {
export interface PrivilegeChangeInput {
user: DatabaseUserIdentity;
privileges: string[];
catalog?: string;
database: string;
table?: string;
grantOption?: boolean;
@@ -38,9 +40,23 @@ export interface PrivilegeSelection {
grantOption: boolean;
}
export interface DatabaseTablePrivilegeGrant {
catalog?: string;
database: string;
schema?: string;
table: string;
privilege: string;
grantOption: boolean;
}
export interface TableGrantParseContext {
database?: string;
}
export interface DatabaseUserAdminProvider {
dialect: UserAdminDialect;
defaultScope: PrivilegeScope;
authorizationModel?: AuthorizationModel;
supportsTableGrantsOnCreate?: boolean;
listUsersSql(): string;
fallbackListUsersSql?: () => string;
@@ -60,6 +76,9 @@ export interface DatabaseUserAdminProvider {
privilegesForScope?(scope: PrivilegeScope): readonly string[];
defaultPrivilegesForScope?(scope: PrivilegeScope): string[];
privilegeSelectionFromGrants?(input: PrivilegeSelectionInput): PrivilegeSelection;
tableGrantsSql?(user: DatabaseUserIdentity): string;
parseTableGrants?(result: QueryResult, context?: TableGrantParseContext): DatabaseTablePrivilegeGrant[];
tableGrantsFromShowGrants?: boolean;
}
export const MYSQL_USER_ADMIN_TYPES = new Set<DatabaseType>(["mysql", "goldendb"]);
@@ -68,10 +87,14 @@ export const POSTGRES_USER_ADMIN_TYPES = new Set<DatabaseType>(["postgres", "gau
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;
// StarRocks' v3.0+ privilege framework uses these table action names; legacy
// *_PRIV names belong to the incompatible pre-v3.0 framework.
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_SCHEMA_PRIVILEGES = ["USAGE", "CREATE"] as const;
// MAINTAIN is PostgreSQL 16+, so keep the picker on the cross-version set until
// provider capabilities can be negotiated from the connected server version.
export const POSTGRES_TABLE_PRIVILEGES = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER"] as const;
export function quoteSqlString(value: string): string {
@@ -214,15 +237,20 @@ export function starrocksPrivilegeTargetSql(database: string, table = "*"): stri
return !db || db === "*" ? `TABLE ${tableName}` : `TABLE ${quoteMySqlIdentifier(db)}.${tableName}`;
}
function starrocksCatalogPrefix(catalog: string | undefined): string {
const name = catalog?.trim();
return name ? `SET CATALOG ${quoteMySqlIdentifier(name)};\n` : "";
}
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};`;
return `${starrocksCatalogPrefix(input.catalog)}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)};`;
return `${starrocksCatalogPrefix(input.catalog)}REVOKE ${privileges} ON ${starrocksPrivilegeTargetSql(input.database, input.table)} FROM USER ${mysqlUserAccount(input.user)};`;
}
export function dorisPrivilegeTargetSql(database: string, table = "*"): string {
@@ -407,6 +435,59 @@ FROM (
ORDER BY sort, line;`.trim();
}
export function postgresTableGrantsSql(user: DatabaseUserIdentity): string {
return `
SELECT
table_schema AS "schema",
table_name AS "table",
privilege_type AS "privilege",
is_grantable AS "grant_option"
FROM information_schema.table_privileges
WHERE grantee = ${quoteSqlString(user.user)}
ORDER BY table_schema, table_name, privilege_type;`.trim();
}
export function postgresTableGrantsResult(result: QueryResult, context: TableGrantParseContext = {}): DatabaseTablePrivilegeGrant[] {
const schemaIndex = columnIndex(result, "schema", "table_schema");
const tableIndex = columnIndex(result, "table", "table_name");
const privilegeIndex = columnIndex(result, "privilege", "privilege_type");
const grantOptionIndex = columnIndex(result, "grant_option", "is_grantable");
const database = context.database?.trim() ?? "";
if (!database || schemaIndex < 0 || tableIndex < 0 || privilegeIndex < 0) return [];
return result.rows.flatMap((row) => {
const schema = String(row[schemaIndex] ?? "").trim();
const table = String(row[tableIndex] ?? "").trim();
const privilege = normalizePrivilegeName(String(row[privilegeIndex] ?? ""));
if (!isPostgresAuthorizationSchema(schema) || !table || !privilege) return [];
return [
{
database,
schema,
table,
privilege,
grantOption:
grantOptionIndex >= 0 &&
String(row[grantOptionIndex] ?? "")
.trim()
.toUpperCase() === "YES",
},
];
});
}
export function starrocksTableGrantsResult(result: QueryResult): DatabaseTablePrivilegeGrant[] {
const catalogIndex = columnIndex(result, "catalog");
const grantsIndex = columnIndex(result, "grants");
if (grantsIndex < 0) return [];
return result.rows.flatMap((row) => {
const sql = String(row[grantsIndex] ?? "").trim();
const catalog = normalizeStarrocksCatalog(catalogIndex >= 0 ? String(row[catalogIndex] ?? "") : "");
return parseStarrocksTableGrant(sql, catalog);
});
}
export function gaussdbMShowGrantsSql(user: DatabaseUserIdentity): string {
const role = quoteSqlString(user.user);
const bypassRls = postgresRoleBypassRlsSql("r");
@@ -654,6 +735,77 @@ function parseMySqlGrant(sql: string): ParsedMySqlGrant | null {
};
}
function parseStarrocksTableGrant(sql: string, catalog: string | undefined): DatabaseTablePrivilegeGrant[] {
const match = /^\s*GRANT\s+(.+?)\s+ON\s+(.+?)\s+TO\s+(?:USER\s+)?/i.exec(sql);
if (!match) return [];
const privileges = match[1]
.split(",")
.map((privilege) => (normalizePrivilegeName(privilege) === "ALL PRIVILEGES" ? "ALL" : normalizePrivilegeName(privilege)))
.filter(Boolean);
if (privileges.length === 0) return [];
const rawTarget = match[2].trim();
let database = "";
let table = "";
const allInDatabase = /^ALL\s+TABLES\s+IN\s+DATABASE\s+(.+)$/i.exec(rawTarget);
if (allInDatabase) {
database = unquoteMySqlIdentifier(allInDatabase[1]);
table = "*";
} else if (/^ALL\s+TABLES\s+IN\s+ALL\s+DATABASES$/i.test(rawTarget)) {
database = "*";
table = "*";
} else {
const tableTarget = rawTarget.replace(/^TABLE\s+/i, "");
if (/^(?:DATABASE|CATALOG|VIEW|MATERIALIZED\s+VIEW|FUNCTION|RESOURCE|USER)\b/i.test(tableTarget)) return [];
const identifiers = splitMySqlIdentifierPath(tableTarget);
if (!identifiers || identifiers.length < 2 || identifiers.length > 3) return [];
if (identifiers.length === 3) catalog = normalizeStarrocksCatalog(identifiers.shift() ?? "") ?? catalog;
[database, table] = identifiers;
}
if (!database || !table) return [];
const grantOption = /\s+WITH\s+GRANT\s+OPTION\s*;?\s*$/i.test(sql);
return privileges.map((privilege) => ({ catalog, database, table, privilege, grantOption }));
}
function splitMySqlIdentifierPath(value: string): string[] | null {
const parts: string[] = [];
let current = "";
let quoted = false;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character === "`") {
if (quoted && value[index + 1] === "`") {
current += "`";
index += 1;
} else {
quoted = !quoted;
}
continue;
}
if (character === "." && !quoted) {
const part = current.trim();
if (!part) return null;
parts.push(part);
current = "";
continue;
}
current += character;
}
if (quoted) return null;
const part = current.trim().replace(/;$/, "").trim();
if (!part) return null;
parts.push(part);
return parts;
}
export function normalizeStarrocksCatalog(value: string): string | undefined {
const catalog = unquoteMySqlIdentifier(value.trim());
if (!catalog || catalog.toUpperCase() === "NULL") return undefined;
return catalog.toLowerCase() === "default" ? "default_catalog" : catalog;
}
function normalizePrivilegeName(value: string): string {
return value.trim().replace(/\s+/g, " ").toUpperCase();
}
@@ -676,6 +828,11 @@ function postgresDefaultPrivilege(scope: PrivilegeScope | undefined): string {
return "SELECT";
}
function isPostgresAuthorizationSchema(schema: string): boolean {
const normalized = schema.toLowerCase();
return !!schema && normalized !== "information_schema" && normalized !== "sys_catalog" && !normalized.startsWith("pg_");
}
function postgresPrivilegesForScope(scope: PrivilegeScope): readonly string[] {
if (scope === "database") return POSTGRES_DATABASE_PRIVILEGES;
if (scope === "schema") return POSTGRES_SCHEMA_PRIVILEGES;
@@ -690,6 +847,7 @@ function postgresRoleBypassRlsSql(alias: string): string {
export const mysqlUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "mysql",
defaultScope: "mysql",
authorizationModel: "mysql",
supportsTableGrantsOnCreate: true,
listUsersSql: mysqlListUsersSql,
fallbackListUsersSql: mysqlListUsersFallbackSql,
@@ -717,6 +875,8 @@ export const nativeMysqlUserAdminProvider: DatabaseUserAdminProvider = {
export const postgresUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "postgres",
defaultScope: "database",
authorizationModel: "postgres",
supportsTableGrantsOnCreate: true,
listUsersSql: postgresListRolesSql,
parseUsers: usersFromPostgresRolesResult,
showGrantsSql: postgresShowGrantsSql,
@@ -730,16 +890,26 @@ export const postgresUserAdminProvider: DatabaseUserAdminProvider = {
detail: (user) => [user.host, user.plugin].filter(Boolean).join(" · ") || undefined,
privilegesForScope: postgresPrivilegesForScope,
defaultPrivilegesForScope: (scope) => (scope === "role" ? [] : [postgresDefaultPrivilege(scope)]),
tableGrantsSql: postgresTableGrantsSql,
parseTableGrants: postgresTableGrantsResult,
};
const postgresCompatibleUserAdminProvider: DatabaseUserAdminProvider = {
...postgresUserAdminProvider,
authorizationModel: undefined,
supportsTableGrantsOnCreate: undefined,
tableGrantsSql: undefined,
parseTableGrants: undefined,
};
export const kingbaseUserAdminProvider: DatabaseUserAdminProvider = {
...postgresUserAdminProvider,
...postgresCompatibleUserAdminProvider,
listUsersSql: kingbaseListRolesSql,
showGrantsSql: kingbaseShowGrantsSql,
};
export const gaussdbMUserAdminProvider: DatabaseUserAdminProvider = {
...postgresUserAdminProvider,
...postgresCompatibleUserAdminProvider,
listUsersSql: gaussdbMListRolesSql,
showGrantsSql: gaussdbMShowGrantsSql,
};
@@ -767,6 +937,8 @@ export const dorisUserAdminProvider: DatabaseUserAdminProvider = {
export const starrocksUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "mysql",
defaultScope: "table",
authorizationModel: "starrocks",
supportsTableGrantsOnCreate: true,
listUsersSql: starrocksListUsersSql,
parseUsers: starrocksUsersResult,
showGrantsSql: mysqlShowGrantsSql,
@@ -780,6 +952,8 @@ export const starrocksUserAdminProvider: DatabaseUserAdminProvider = {
detail: (user) => user.plugin,
privilegesForScope: () => STARROCKS_TABLE_PRIVILEGES,
defaultPrivilegesForScope: () => ["SELECT"],
parseTableGrants: starrocksTableGrantsResult,
tableGrantsFromShowGrants: true,
};
const DATABASE_USER_ADMIN_PROVIDER_BY_TYPE = new Map<DatabaseType, DatabaseUserAdminProvider>([
@@ -788,12 +962,12 @@ const DATABASE_USER_ADMIN_PROVIDER_BY_TYPE = new Map<DatabaseType, DatabaseUserA
["doris", dorisUserAdminProvider],
["kingbase", kingbaseUserAdminProvider],
["postgres", postgresUserAdminProvider],
["gaussdb", postgresUserAdminProvider],
["highgo", postgresUserAdminProvider],
["kwdb", postgresUserAdminProvider],
["opengauss", postgresUserAdminProvider],
["questdb", postgresUserAdminProvider],
["vastbase", postgresUserAdminProvider],
["gaussdb", postgresCompatibleUserAdminProvider],
["highgo", postgresCompatibleUserAdminProvider],
["kwdb", postgresCompatibleUserAdminProvider],
["opengauss", postgresCompatibleUserAdminProvider],
["questdb", postgresCompatibleUserAdminProvider],
["vastbase", postgresCompatibleUserAdminProvider],
["starrocks", starrocksUserAdminProvider],
]);