mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
@@ -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);
|
||||
|
||||
+137
-11
@@ -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],
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user