feat(users): visual database and table level grants for MySQL

This commit is contained in:
高桥凉介 authored and GitHub committed 2026-09-21 11:36:34 +08:00
1 parent c7660b20d0
commit 99c172b24d
15 files changed
+643 -271

No files matched your search

@@ -0,0 +1,328 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Check, ChevronDown, Loader2, Search, Table2 } from "@lucide/vue";
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 type { DatabaseUserAdminProvider } from "@/lib/database/databaseUserAdmin";
/**
* 数据库授权范围编辑器:数据库多选 + 每库权限预设 + 表级(全部表/指定表)选择 + 自定义权限勾选。
* 该组件同时被“新增用户”弹窗与“权限编辑”面板复用,保证两处交互一致。
* 组件内部只负责收集选择结果(DatabaseAuthorizationSelection[]),不生成或执行 SQL,
* 授权语句由调用方交给 databaseAuthorizationPlan 中的计划构造器生成。
*/
const props = defineProps<{
/** 已选中的数据库授权配置,由调用方通过 v-model 持有 */
modelValue: DatabaseAuthorizationSelection[];
/** 当前连接的权限提供者,用于判断是否支持表级授权以及表级可用权限 */
provider: DatabaseUserAdminProvider | null;
/** 当前连接下的数据库名列表 */
databases: string[];
/** 数据库列表是否正在加载 */
databasesLoading?: boolean;
/** 连接 ID,用于按库懒加载表列表 */
connectionId: string;
/** 紧凑布局:用于右侧权限编辑面板,隐藏标题区、压缩列表高度并让表范围控件换行显示 */
compact?: boolean;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: DatabaseAuthorizationSelection[]): void;
}>();
const { t } = useI18n();
// 全局作用域标识:MySQL 的 GRANT/REVOKE 支持 ON *.*,权限编辑面板需要保留这一能力
const GLOBAL_SCOPE = "*";
const search = ref("");
// 表列表按库懒加载并缓存,避免下拉展开时重复请求
const tables = ref<Record<string, string[]>>({});
const tablesLoading = ref<Record<string, boolean>>({});
const tableErrors = ref<Record<string, string>>({});
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;
});
// 紧凑模式(权限编辑面板)在列表顶部提供固定的「全局」入口,且不受库搜索影响;
// 新增用户弹窗保持只勾选真实数据库,不改变原有产品行为。
const visibleDatabases = computed(() => (props.compact ? [GLOBAL_SCOPE, ...filteredDatabases.value] : filteredDatabases.value));
const selectedDatabaseSet = computed(() => new Set(props.modelValue.map((selection) => selection.database)));
// 同一 tick 内连续触发多次选择变更时(例如连续点击多张表),父组件回流的 props 尚未更新,
// 这里用最近一次发出的值作为基准,避免后一次变更覆盖前一次导致选择丢失。
let pendingSelections: DatabaseAuthorizationSelection[] | null = null;
watch(
() => props.modelValue,
(value) => {
pendingSelections = value;
},
);
function currentSelections(): DatabaseAuthorizationSelection[] {
return pendingSelections ?? props.modelValue;
}
function commitSelections(next: DatabaseAuthorizationSelection[]) {
pendingSelections = next;
emit("update:modelValue", next);
}
function selectionFor(database: string): DatabaseAuthorizationSelection | undefined {
return props.modelValue.find((selection) => selection.database === database);
}
/**
* 统一以“不可变更新”方式回写选中项:始终替换为新的数组与对象,
* 避免直接修改 props 中的对象引用(v-model 单向数据流的约束)。
*/
function patchSelection(database: string, patch: (selection: DatabaseAuthorizationSelection) => DatabaseAuthorizationSelection | undefined) {
const next: DatabaseAuthorizationSelection[] = [];
for (const selection of currentSelections()) {
if (selection.database !== 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 });
}
commitSelections(next);
}
function toggleDatabase(database: string) {
if (selectionFor(database)) {
commitSelections(currentSelections().filter((selection) => selection.database !== database));
return;
}
commitSelections([...currentSelections(), { database, preset: "readOnly", privileges: ["SELECT"] }]);
}
// 表范围下 MySQL 不支持 CREATE ROUTINE / EVENT 等库级权限,需要按目标作用域过滤可选权限
function privilegesForSelection(selection?: DatabaseAuthorizationSelection): string[] {
const userProvider = props.provider;
if (!userProvider) return [];
return authorizationPrivileges(userProvider, selection?.tables === undefined ? "database" : "table");
}
function usesSelectedTables(database: string): boolean {
return selectionFor(database)?.tables !== undefined;
}
function isTableSelected(database: string, table: string): boolean {
return selectionFor(database)?.tables?.includes(table) ?? false;
}
function allTablesSelected(database: string): boolean {
const available = tables.value[database] ?? [];
return available.length > 0 && selectionFor(database)?.tables?.length === available.length;
}
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;
}
async function loadTables(database: string) {
if (tables.value[database] || tablesLoading.value[database]) return;
tablesLoading.value = { ...tablesLoading.value, [database]: true };
tableErrors.value = { ...tableErrors.value, [database]: "" };
try {
const result = await api.listTables(props.connectionId, database, "");
tables.value = {
...tables.value,
[database]: Array.from(new Set(result.map((table) => table.name.trim()).filter(Boolean))).sort((left, right) => left.localeCompare(right)),
};
} catch (error: any) {
tableErrors.value = { ...tableErrors.value, [database]: error?.message || String(error) };
} finally {
tablesLoading.value = { ...tablesLoading.value, [database]: false };
}
}
async function updateTableScope(database: string, selectTables: boolean) {
if (!selectTables) {
patchSelection(database, (selection) => {
delete selection.tables;
return selection;
});
return;
}
patchSelection(database, (selection) => {
selection.tables = selection.tables ?? [];
if (selection.preset === "custom") {
const allowed = new Set(privilegesForSelection(selection));
selection.privileges = (selection.privileges ?? []).filter((privilege) => allowed.has(privilege));
}
return selection;
});
await loadTables(database);
}
function toggleTable(database: string, table: string) {
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);
return selection;
});
}
function toggleAllTables(database: string) {
patchSelection(database, (selection) => {
if (!selection.tables) return selection;
const available = tables.value[database] ?? [];
selection.tables = selection.tables.length === available.length ? [] : [...available];
return selection;
});
}
function updatePreset(database: string, preset: unknown) {
if (typeof preset !== "string") return;
patchSelection(database, (selection) => {
selection.preset = preset as AuthorizationPreset;
return selection;
});
}
function togglePrivilege(database: string, privilege: string) {
patchSelection(database, (selection) => {
const next = new Set(selection.privileges ?? []);
if (next.has(privilege)) next.delete(privilege);
else next.add(privilege);
selection.privileges = Array.from(next);
return selection;
});
}
function isPrivilegeSelected(database: string, privilege: string): boolean {
return selectionFor(database)?.privileges?.includes(privilege) ?? false;
}
</script>
<template>
<div class="flex flex-col gap-2" :class="compact ? 'h-full min-h-0' : ''">
<div v-if="!compact" class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-medium">{{ t("userAdmin.databaseAccess") }}</div>
<div class="mt-1 text-[11px] text-muted-foreground">{{ t("userAdmin.databaseAccessHint") }}</div>
</div>
<Badge variant="outline">{{ t("userAdmin.selectedDatabaseCount", { count: modelValue.length }) }}</Badge>
</div>
<div class="flex h-8 shrink-0 items-center gap-2 rounded-md border px-2">
<Search class="h-3.5 w-3.5 text-muted-foreground" />
<input v-model="search" class="min-w-0 flex-1 bg-transparent text-xs outline-none" :placeholder="t('userAdmin.searchDatabase')" />
</div>
<!-- 紧凑模式下面板高度有限,数据库列表占满剩余高度并自行滚动,避免多个库挤在一起 -->
<div class="overflow-auto rounded-md border" :class="compact ? 'min-h-0 flex-1' : 'max-h-64'">
<div v-if="databasesLoading" class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ 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 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)" />
<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 }}
</button>
<Select v-if="selectedDatabaseSet.has(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>
<SelectItem value="readOnly">{{ t("userAdmin.presetReadOnly") }}</SelectItem>
<SelectItem value="ddl">{{ t("userAdmin.presetDdl") }}</SelectItem>
<SelectItem value="dml">{{ t("userAdmin.presetDml") }}</SelectItem>
<SelectItem value="custom">{{ t("userAdmin.presetCustom") }}</SelectItem>
</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 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">
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="!usesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateTableScope(database, false)">
{{ t("userAdmin.allTables") }}
</button>
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="usesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateTableScope(database, true)">
{{ t("userAdmin.specificTables") }}
</button>
</div>
</div>
<Popover v-if="usesSelectedTables(database)">
<PopoverTrigger as-child>
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border bg-background px-2 text-left hover:bg-accent" :class="compact ? 'h-9 text-xs' : 'h-7 text-[11px]'">
<Table2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<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" />
<ChevronDown v-else class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent align="end" class="w-72 p-0">
<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')" />
</div>
<button type="button" class="shrink-0 text-[11px] text-primary disabled:text-muted-foreground" :disabled="!(tables[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">
<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>
<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)">
<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>
</button>
</div>
</PopoverContent>
</Popover>
</div>
<p v-if="usesSelectedTables(database) && !selectionFor(database)?.tables?.length" class="mt-1 text-[10px] text-destructive" :class="compact ? '' : 'pl-5'">
{{ t("userAdmin.tableSelectionRequired") }}
</p>
<div v-if="selectionFor(database)?.preset === 'custom'" class="mt-2 grid gap-1.5" :class="compact ? 'grid-cols-2' : 'grid-cols-3 pl-5'">
<button
v-for="privilege in privilegesForSelection(selectionFor(database))"
:key="privilege"
type="button"
:title="privilege"
class="flex h-7 min-w-0 items-center gap-1.5 rounded border px-2 text-[10px]"
:class="isPrivilegeSelected(database, privilege) ? 'border-primary bg-primary/10 text-primary' : 'bg-background'"
@click="togglePrivilege(database, privilege)"
>
<Check v-if="isPrivilegeSelected(database, privilege)" class="h-3 w-3 shrink-0" />
<span class="min-w-0 flex-1 truncate text-left">{{ privilege }}</span>
</button>
<p v-if="!selectionFor(database)?.privileges?.length" class="text-[10px] text-destructive" :class="compact ? 'col-span-2' : 'col-span-3'">
{{ 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'">
{{ t("userAdmin.postgresDdlHint") }}
</p>
</div>
</div>
</div>
</template>
@@ -1,14 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Check, ChevronDown, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Table2, Trash2, Unlock, UserRound } from "@lucide/vue";
import { AlertTriangle, Check, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Unlock, UserRound } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import AuthorizationScopeEditor from "@/components/admin/AuthorizationScopeEditor.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { fetchNamespaceOptionsForConnection } from "@/composables/useDatabaseOptions";
@@ -22,10 +22,10 @@ import {
authorizationPlanStatus,
authorizationPrivileges,
buildCreateUserAuthorizationPlan,
buildGrantAuthorizationPlan,
executeAuthorizationPlan,
type AuthorizationAccountType,
type AuthorizationPlan,
type AuthorizationPreset,
type AuthorizationStepResult,
type DatabaseAuthorizationSelection,
} from "@/lib/database/databaseAuthorizationPlan";
@@ -90,12 +90,9 @@ const createCanLogin = ref(true);
const createAccountType = ref<AuthorizationAccountType>("standard");
const createDatabases = ref<string[]>([]);
const createDatabasesLoading = ref(false);
const createDatabaseSearch = ref("");
const createDatabaseAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
const createDatabaseTables = ref<Record<string, string[]>>({});
const createDatabaseTablesLoading = ref<Record<string, boolean>>({});
const createDatabaseTableErrors = ref<Record<string, string>>({});
const createDatabaseTableSearch = ref<Record<string, string>>({});
// 权限编辑面板的授权范围选择(MySQL 表级授权场景),与新增用户弹窗复用同一个范围编辑器
const privilegeAuthorizations = ref<DatabaseAuthorizationSelection[]>([]);
let createPlanRequestId = 0;
let preserveRestoredPrivilegeSelection = restoredUiState.selectedPrivileges !== undefined || restoredUiState.grantOption !== undefined;
@@ -141,18 +138,10 @@ const selectedDetail = computed(() => {
const grantsSqlText = computed(() => grants.value.join("\n") || t("userAdmin.noGrants"));
const highlightedGrantsSql = computed(() => highlight(grantsSqlText.value));
const highlightedPendingSql = computed(() => highlight(pendingSql.value));
const filteredCreateDatabases = computed(() => {
const query = createDatabaseSearch.value.trim().toLowerCase();
return query ? createDatabases.value.filter((database) => database.toLowerCase().includes(query)) : createDatabases.value;
});
const selectedCreateDatabaseSet = computed(() => new Set(createDatabaseAuthorizations.value.map((selection) => selection.database)));
const createDatabaseAuthorizationsValid = computed(() =>
createDatabaseAuthorizations.value.every((selection) => {
if (selection.tables !== undefined && selection.tables.length === 0) return false;
if (selection.preset !== "custom") return true;
return createPrivilegesForAuthorization(selection).some((privilege) => selection.privileges?.includes(privilege));
}),
);
const createDatabaseAuthorizationsValid = computed(() => authorizationSelectionsValid(createDatabaseAuthorizations.value));
// MySQL 表级授权场景下,权限编辑面板复用与新增用户一致的授权范围编辑器
const usePrivilegeScopeEditor = computed(() => canEditPrivileges.value && supportsCreateTableGrants.value && privilegeScope.value !== "role");
const privilegeAuthorizationsValid = computed(() => authorizationSelectionsValid(privilegeAuthorizations.value));
const pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined));
const canPreviewHostChange = computed(() => {
const host = newHost.value.trim();
@@ -267,17 +256,8 @@ function togglePrivilege(privilege: string) {
selectedPrivileges.value = Array.from(set);
}
async function openCreateUserDialog() {
createDialogOpen.value = true;
createAccountType.value = "standard";
createCanLogin.value = true;
createPassword.value = "";
createDatabaseSearch.value = "";
createDatabaseAuthorizations.value = [];
createDatabaseTables.value = {};
createDatabaseTablesLoading.value = {};
createDatabaseTableErrors.value = {};
createDatabaseTableSearch.value = {};
// 加载当前连接下的数据库列表:新增用户弹窗与权限编辑面板的授权范围编辑器共用
async function loadDatabases() {
if (createDatabasesLoading.value) return;
createDatabases.value = [];
createDatabasesLoading.value = true;
@@ -292,108 +272,27 @@ async function openCreateUserDialog() {
}
}
function createAuthorization(database: string): DatabaseAuthorizationSelection | undefined {
return createDatabaseAuthorizations.value.find((selection) => selection.database === database);
async function openCreateUserDialog() {
createDialogOpen.value = true;
createAccountType.value = "standard";
createCanLogin.value = true;
createPassword.value = "";
createDatabaseAuthorizations.value = [];
await loadDatabases();
}
function toggleCreateDatabase(database: string) {
const existing = createAuthorization(database);
if (existing) {
createDatabaseAuthorizations.value = createDatabaseAuthorizations.value.filter((selection) => selection.database !== database);
} else {
createDatabaseAuthorizations.value = [...createDatabaseAuthorizations.value, { database, preset: "readOnly", privileges: ["SELECT"] }];
}
}
function createPrivilegesForAuthorization(selection?: DatabaseAuthorizationSelection): string[] {
/**
* 校验授权选择是否可生成语句:指定表时必须至少选一张表;自定义权限时必须命中至少一项可用权限。
* 表级与库级可用权限不同(例如 MySQL 表级不支持 CREATE ROUTINE / EVENT),因此按选择的目标作用域取权限集合。
*/
function authorizationSelectionsValid(selections: DatabaseAuthorizationSelection[]): boolean {
const userProvider = provider.value;
if (!userProvider || !selection) return [];
return authorizationPrivileges(userProvider, selection.tables === undefined ? "database" : "table");
}
function createAuthorizationUsesSelectedTables(database: string): boolean {
return createAuthorization(database)?.tables !== undefined;
}
function createAuthorizationTableSelected(database: string, table: string): boolean {
return createAuthorization(database)?.tables?.includes(table) ?? false;
}
function allCreateDatabaseTablesSelected(database: string): boolean {
const tables = createDatabaseTables.value[database] ?? [];
return tables.length > 0 && createAuthorization(database)?.tables?.length === tables.length;
}
function filteredCreateDatabaseTables(database: string): string[] {
const query = (createDatabaseTableSearch.value[database] ?? "").trim().toLowerCase();
const tables = createDatabaseTables.value[database] ?? [];
return query ? tables.filter((table) => table.toLowerCase().includes(query)) : tables;
}
async function updateCreateDatabaseTableScope(database: string, selectedTables: boolean) {
const selection = createAuthorization(database);
if (!selection) return;
if (!selectedTables) {
delete selection.tables;
return;
}
selection.tables = selection.tables ?? [];
if (selection.preset === "custom") {
const allowed = new Set(createPrivilegesForAuthorization(selection));
selection.privileges = (selection.privileges ?? []).filter((privilege) => allowed.has(privilege));
}
await loadCreateDatabaseTables(database);
}
async function loadCreateDatabaseTables(database: string) {
if (createDatabaseTables.value[database] || createDatabaseTablesLoading.value[database]) return;
createDatabaseTablesLoading.value = { ...createDatabaseTablesLoading.value, [database]: true };
createDatabaseTableErrors.value = { ...createDatabaseTableErrors.value, [database]: "" };
try {
const tables = await api.listTables(props.connection.id, database, "");
createDatabaseTables.value = {
...createDatabaseTables.value,
[database]: Array.from(new Set(tables.map((table) => table.name.trim()).filter(Boolean))).sort((left, right) => left.localeCompare(right)),
};
} catch (error: any) {
createDatabaseTableErrors.value = {
...createDatabaseTableErrors.value,
[database]: error?.message || String(error),
};
} finally {
createDatabaseTablesLoading.value = { ...createDatabaseTablesLoading.value, [database]: false };
}
}
function toggleCreateDatabaseTable(database: string, table: string) {
const selection = createAuthorization(database);
if (!selection?.tables) return;
const tables = new Set(selection.tables);
if (tables.has(table)) tables.delete(table);
else tables.add(table);
selection.tables = Array.from(tables);
}
function toggleAllCreateDatabaseTables(database: string) {
const selection = createAuthorization(database);
if (!selection?.tables) return;
const tables = createDatabaseTables.value[database] ?? [];
selection.tables = selection.tables.length === tables.length ? [] : [...tables];
}
function updateCreateDatabasePreset(database: string, preset: unknown) {
const selection = createAuthorization(database);
if (!selection || typeof preset !== "string") return;
selection.preset = preset as AuthorizationPreset;
}
function toggleCreateDatabasePrivilege(database: string, privilege: string) {
const selection = createAuthorization(database);
if (!selection) return;
const privileges = new Set(selection.privileges ?? []);
if (privileges.has(privilege)) privileges.delete(privilege);
else privileges.add(privilege);
selection.privileges = Array.from(privileges);
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));
});
}
function previewSql(sql: string, options: { danger?: boolean; afterApply?: () => Promise<void> } = {}) {
@@ -495,6 +394,9 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
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 });
}
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 });
}
if (step.operation === "grantCurrentObjects") {
return t("userAdmin.stepGrantCurrentObjects", {
user: step.subject,
@@ -564,11 +466,31 @@ function previewLoginChange(enabled: boolean) {
previewSql(alterLoginSql(user, enabled), { danger: true });
}
/**
* 打开授权计划预览:多库/多表会展开为多条 GRANT / REVOKE,
* 复用既有 SQL 预览弹窗的逐步执行结果与生产环境守卫,无需新增执行链路。
*/
function previewPlan(plan: AuthorizationPlan, options: { danger?: boolean } = {}) {
if (plan.steps.length === 0) return;
pendingPlan.value = plan;
pendingSql.value = authorizationPlanSql(plan);
pendingResults.value = [];
pendingDanger.value = !!options.danger;
pendingAfterApply.value = undefined;
sqlDialogOpen.value = true;
}
function previewGrant() {
const user = selectedUser.value;
const userProvider = provider.value;
const grantPrivilegesSql = userProvider?.grantPrivilegesSql;
if (!user || !grantPrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
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 }));
return;
}
previewSql(
grantPrivilegesSql({
user,
@@ -586,7 +508,13 @@ function previewRevoke() {
const user = selectedUser.value;
const userProvider = provider.value;
const revokePrivilegesSql = userProvider?.revokePrivilegesSql;
if (!user || !revokePrivilegesSql || (privilegeScope.value === "role" && !privilegeRole.value.trim())) return;
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 });
return;
}
previewSql(
revokePrivilegesSql({
user,
@@ -624,6 +552,8 @@ watch(
() => selectedUserKey.value,
() => {
grantsLoaded.value = false;
// 切换用户时清空上一位用户选择的授权范围,避免把权限误授给当前用户
privilegeAuthorizations.value = [];
void loadGrants();
},
);
@@ -634,16 +564,14 @@ watch(
users.value = [];
createDatabases.value = [];
createDatabaseAuthorizations.value = [];
createDatabaseTables.value = {};
createDatabaseTablesLoading.value = {};
createDatabaseTableErrors.value = {};
createDatabaseTableSearch.value = {};
privilegeAuthorizations.value = [];
selectedUserKey.value = "";
grants.value = [];
grantsLoaded.value = false;
privilegeScope.value = provider.value?.defaultScope ?? "mysql";
resetPrivilegeDefaults(privilegeScope.value);
void loadUsers();
if (usePrivilegeScopeEditor.value) void loadDatabases();
},
);
@@ -671,7 +599,11 @@ watch(
watch([privilegeDatabase, privilegeTable], syncPrivilegeSelectionFromGrants);
onMounted(loadUsers);
onMounted(() => {
void loadUsers();
// 权限编辑面板的授权范围选择需要数据库列表,进入页面即预加载(仅 MySQL 表级授权场景)
if (usePrivilegeScopeEditor.value) void loadDatabases();
});
</script>
<template>
@@ -789,7 +721,7 @@ onMounted(loadUsers);
<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("userAdmin.privilegeHint") }}</div>
<div class="mt-1 text-[11px] leading-4 text-muted-foreground">{{ t(usePrivilegeScopeEditor ? "userAdmin.privilegeAppendHint" : "userAdmin.privilegeHint") }}</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-3">
<template v-if="isPostgres">
@@ -811,6 +743,7 @@ onMounted(loadUsers);
<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" />
<template v-else>
<label class="mb-2 block text-xs font-medium">
{{ isPostgres && privilegeScope !== "database" ? t("userAdmin.schema") : t("userAdmin.database") }}
@@ -822,8 +755,8 @@ onMounted(loadUsers);
</template>
</template>
<div v-if="hasPrivilegePicker" class="mb-2 text-xs font-medium">{{ t("userAdmin.privileges") }}</div>
<div v-if="hasPrivilegePicker" class="grid grid-cols-2 gap-1.5">
<div v-if="hasPrivilegePicker && !usePrivilegeScopeEditor" class="mb-2 text-xs font-medium">{{ t("userAdmin.privileges") }}</div>
<div v-if="hasPrivilegePicker && !usePrivilegeScopeEditor" class="grid grid-cols-2 gap-1.5">
<button
v-for="privilege in availablePrivileges"
:key="privilege"
@@ -838,16 +771,20 @@ onMounted(loadUsers);
<span class="truncate">{{ privilege }}</span>
</button>
</div>
<label class="mt-3 flex items-center gap-2 text-xs">
<input v-model="grantOption" type="checkbox" class="h-3.5 w-3.5 accent-primary" />
{{ privilegeScope === "role" ? t("userAdmin.adminOption") : t("userAdmin.grantOption") }}
</label>
</div>
<label class="flex shrink-0 items-start gap-2 border-t px-3 py-2 text-xs leading-4">
<input v-model="grantOption" type="checkbox" class="mt-0.5 h-3.5 w-3.5 shrink-0 accent-primary" />
<span class="min-w-0 flex-1">
{{ privilegeScope === "role" ? t("userAdmin.adminOption") : t("userAdmin.grantOption") }}
<!-- 撤权语句本身不带 GRANT OPTION,明确说明避免误解 -->
<span v-if="privilegeScope !== 'role'" class="mt-0.5 block text-[10px] text-muted-foreground">{{ t("userAdmin.grantOptionHint") }}</span>
</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" @click="previewRevoke">
<Button v-if="canRevokePrivileges" variant="outline" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewRevoke">
{{ t("userAdmin.revoke") }}
</Button>
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" @click="previewGrant">
<Button v-if="canGrantPrivileges" size="sm" class="h-7 px-2 text-xs" :disabled="usePrivilegeScopeEditor && !privilegeAuthorizationsValid" @click="previewGrant">
{{ t("userAdmin.grant") }}
</Button>
</div>
@@ -893,117 +830,7 @@ onMounted(loadUsers);
{{ t("userAdmin.adminUserWarning") }}
</div>
<div v-else class="grid gap-2">
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs font-medium">{{ t("userAdmin.databaseAccess") }}</div>
<div class="mt-1 text-[11px] text-muted-foreground">{{ t("userAdmin.databaseAccessHint") }}</div>
</div>
<Badge variant="outline">{{ t("userAdmin.selectedDatabaseCount", { count: createDatabaseAuthorizations.length }) }}</Badge>
</div>
<div class="flex h-8 items-center gap-2 rounded-md border px-2">
<Search class="h-3.5 w-3.5 text-muted-foreground" />
<input v-model="createDatabaseSearch" class="min-w-0 flex-1 bg-transparent text-xs outline-none" :placeholder="t('userAdmin.searchDatabase')" />
</div>
<div class="max-h-64 overflow-auto rounded-md border">
<div v-if="createDatabasesLoading" class="flex items-center gap-2 p-3 text-xs text-muted-foreground">
<Loader2 class="h-3.5 w-3.5 animate-spin" />
{{ t("userAdmin.loadingDatabases") }}
</div>
<div v-else-if="filteredCreateDatabases.length === 0" class="p-3 text-center text-xs text-muted-foreground">{{ t("userAdmin.emptyDatabases") }}</div>
<div v-for="database in filteredCreateDatabases" :key="database" class="border-b p-2 last:border-b-0">
<div class="flex items-center gap-2">
<input :checked="selectedCreateDatabaseSet.has(database)" type="checkbox" class="h-3.5 w-3.5 accent-primary" @change="toggleCreateDatabase(database)" />
<button type="button" class="min-w-0 flex-1 truncate text-left text-xs font-medium" @click="toggleCreateDatabase(database)">{{ database }}</button>
<Select v-if="selectedCreateDatabaseSet.has(database)" :model-value="createAuthorization(database)?.preset" @update:model-value="updateCreateDatabasePreset(database, $event)">
<SelectTrigger class="h-7 w-32 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="readWrite">{{ t("userAdmin.presetReadWrite") }}</SelectItem>
<SelectItem value="readOnly">{{ t("userAdmin.presetReadOnly") }}</SelectItem>
<SelectItem value="ddl">{{ t("userAdmin.presetDdl") }}</SelectItem>
<SelectItem value="dml">{{ t("userAdmin.presetDml") }}</SelectItem>
<SelectItem value="custom">{{ t("userAdmin.presetCustom") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div v-if="supportsCreateTableGrants && selectedCreateDatabaseSet.has(database)" class="mt-2 flex items-center gap-2 pl-5">
<span 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">
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="!createAuthorizationUsesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateCreateDatabaseTableScope(database, false)">
{{ t("userAdmin.allTables") }}
</button>
<button type="button" class="h-5 rounded px-2 text-[10px]" :class="createAuthorizationUsesSelectedTables(database) ? 'bg-background font-medium shadow-sm' : 'text-muted-foreground'" @click="updateCreateDatabaseTableScope(database, true)">
{{ t("userAdmin.specificTables") }}
</button>
</div>
<Popover v-if="createAuthorizationUsesSelectedTables(database)">
<PopoverTrigger as-child>
<button type="button" class="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border bg-background px-2 text-left text-[11px] hover:bg-accent">
<Table2 class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">
{{ createAuthorization(database)?.tables?.length ? t("userAdmin.selectedTableCount", { count: createAuthorization(database)?.tables?.length }) : t("userAdmin.chooseTables") }}
</span>
<Loader2 v-if="createDatabaseTablesLoading[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>
<PopoverContent align="end" class="w-72 p-0">
<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="createDatabaseTableSearch[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="!(createDatabaseTables[database]?.length > 0)" @click="toggleAllCreateDatabaseTables(database)">
{{ t(allCreateDatabaseTablesSelected(database) ? "userAdmin.clearAllTables" : "userAdmin.selectAllTables") }}
</button>
</div>
<div class="max-h-56 overflow-auto p-1">
<div v-if="createDatabaseTablesLoading[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="createDatabaseTableErrors[database]" class="px-3 py-4 text-center text-xs text-destructive">
<p class="break-words">{{ t("userAdmin.loadTablesFailed", { message: createDatabaseTableErrors[database] }) }}</p>
<button type="button" class="mt-2 text-primary" @click="loadCreateDatabaseTables(database)">{{ t("userAdmin.retry") }}</button>
</div>
<div v-else-if="filteredCreateDatabaseTables(database).length === 0" class="px-3 py-6 text-center text-xs text-muted-foreground">
{{ t("userAdmin.emptyTables") }}
</div>
<button v-for="table in filteredCreateDatabaseTables(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="toggleCreateDatabaseTable(database, table)">
<span class="flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border" :class="createAuthorizationTableSelected(database, table) ? 'border-primary bg-primary text-primary-foreground' : 'border-border'">
<Check v-if="createAuthorizationTableSelected(database, table)" class="h-2.5 w-2.5" />
</span>
<span class="min-w-0 flex-1 truncate">{{ table }}</span>
</button>
</div>
</PopoverContent>
</Popover>
</div>
<p v-if="createAuthorizationUsesSelectedTables(database) && !createAuthorization(database)?.tables?.length" class="mt-1 pl-5 text-[10px] text-destructive">
{{ t("userAdmin.tableSelectionRequired") }}
</p>
<div v-if="createAuthorization(database)?.preset === 'custom'" class="mt-2 grid grid-cols-3 gap-1.5 pl-5">
<button
v-for="privilege in createPrivilegesForAuthorization(createAuthorization(database))"
:key="privilege"
type="button"
class="flex h-7 items-center gap-1.5 rounded border px-2 text-[10px]"
:class="createAuthorization(database)?.privileges?.includes(privilege) ? 'border-primary bg-primary/10 text-primary' : 'bg-background'"
@click="toggleCreateDatabasePrivilege(database, privilege)"
>
<Check v-if="createAuthorization(database)?.privileges?.includes(privilege)" class="h-3 w-3" />
<span class="truncate">{{ privilege }}</span>
</button>
<p v-if="!createAuthorization(database)?.privileges?.length" class="col-span-3 text-[10px] text-destructive">
{{ t("userAdmin.customPrivilegeRequired") }}
</p>
</div>
<p v-if="isPostgres && selectedCreateDatabaseSet.has(database) && createAuthorization(database)?.preset === 'ddl'" class="mt-2 pl-5 text-[10px] text-muted-foreground">
{{ t("userAdmin.postgresDdlHint") }}
</p>
</div>
</div>
</div>
<AuthorizationScopeEditor v-else v-model="createDatabaseAuthorizations" :provider="provider" :databases="createDatabases" :databases-loading="createDatabasesLoading" :connection-id="connection.id" />
</div>
<DialogFooter>
<Button variant="outline" @click="createDialogOpen = false">{{ t("dangerDialog.cancel") }}</Button>
+5
View File
@@ -3993,6 +3993,8 @@ export default withEnglishFallback({
noGrants: "Verilmiş səlahiyyət tapılmadı",
privilegeEditor: "Səlahiyyət redaktoru",
privilegeHint: "Səlahiyyətləri, verilənlər bazasını və cədvəli seçin, sonra tətbiq etməzdən əvvəl SQL-ə önbaxış edin. * qlobal və ya bütün cədvəllər deməkdir.",
privilegeAppendHint: "Verilənlər bazalarını seçin və cədvəl əhatəsini təyin edin, sonra hər verilənlər bazası üçün imtiyazları konfiqurasiya edin. İmtiyazlar əlavə qaydada verilir və mövcud imtiyazlar geri alınmır.",
globalScope: "Qlobal (*.*)",
database: "Verilənlər bazası",
schema: "Sxem",
table: "Cədvəl",
@@ -4004,6 +4006,7 @@ export default withEnglishFallback({
memberRole: "Üzv rol",
privileges: "Səlahiyyətlər",
grantOption: "Səlahiyyətlərin başqalarına verilməsinə icazə ver (WITH GRANT OPTION)",
grantOptionHint: "Yalnızca GRANT ifadələri üçün tətbiq olunur; REVOKE bunu nəzərə almır.",
adminOption: "Üzvlüyün idarə olunmasına icazə ver (WITH ADMIN OPTION)",
grant: "Səlahiyyət ver",
revoke: "Geri al",
@@ -4068,6 +4071,8 @@ export default withEnglishFallback({
stepGrantAdmin: "{user} istifadəçisinə inzibatçı səlahiyyətləri ver",
stepGrantDatabase: '{user} istifadəçisinə "{database}" verilənlər bazasına giriş icazəsi ver',
stepGrantTable: '{user} istifadəçisinə "{database}.{table}" cədvəlinə giriş icazəsi ver',
stepRevokeDatabase: '{user} istifadəçisinin "{database}" verilənlər bazasına girişini geri al',
stepRevokeTable: '{user} istifadəçisinin "{database}.{table}" cədvəlinə girişini geri al',
stepGrantObjects: '{user} istifadəçisinə "{database}" verilənlər bazasında sxem və obyekt icazələri ver',
stepGrantCurrentObjects: '"{database}" verilənlər bazasındakı "{schema}" sxemində mövcud olan {scope} üçün {user} istifadəçisinə icazə ver',
stepGrantFutureObjects: '"{database}" verilənlər bazasında cari rolun gələcəkdə yaradacağı {scope} üçün {user} istifadəçisinə icazə ver',
+5
View File
@@ -4215,6 +4215,8 @@ export default {
noGrants: "No grants found",
privilegeEditor: "Privilege Editor",
privilegeHint: "Choose privileges, database, and table, then preview SQL before applying. * means global or all tables.",
privilegeAppendHint: "Choose databases and specify table scope, then configure privileges per database. Grants are additive and never revoke existing privileges.",
globalScope: "Global (*.*)",
database: "Database",
schema: "Schema",
table: "Table",
@@ -4226,6 +4228,7 @@ export default {
memberRole: "Member role",
privileges: "Privileges",
grantOption: "Allow further grants (WITH GRANT OPTION)",
grantOptionHint: "Applies to GRANT statements only; REVOKE ignores it.",
adminOption: "Allow membership management (WITH ADMIN OPTION)",
grant: "Grant",
revoke: "Revoke",
@@ -4290,6 +4293,8 @@ export default {
stepGrantAdmin: "Grant administrative privileges to {user}",
stepGrantDatabase: 'Grant {user} access to database "{database}"',
stepGrantTable: 'Grant {user} access to table "{database}.{table}"',
stepRevokeDatabase: 'Revoke {user} access to database "{database}"',
stepRevokeTable: 'Revoke {user} access to table "{database}.{table}"',
stepGrantObjects: 'Grant {user} schema and object privileges in "{database}"',
stepGrantCurrentObjects: 'Grant current {scope} in schema "{schema}" to {user} in "{database}"',
stepGrantFutureObjects: 'Grant future {scope} created by the current role to {user} in "{database}"',
+5
View File
@@ -3912,6 +3912,8 @@ export default withEnglishFallback({
noGrants: "No se encontraron permisos",
privilegeEditor: "Editor de privilegios",
privilegeHint: "Elige privilegios, base de datos y tabla, luego previsualiza el SQL antes de aplicar. * significa global o todas las tablas.",
privilegeAppendHint: "Elige bases de datos y especifica el ámbito de tablas, luego configura los privilegios por base de datos. La concesión es acumulativa y no revoca los privilegios existentes.",
globalScope: "Global (*.*)",
database: "Base de datos",
schema: "Esquema",
table: "Tabla",
@@ -3923,6 +3925,7 @@ export default withEnglishFallback({
memberRole: "Rol miembro",
privileges: "Privilegios",
grantOption: "Permitir más concesiones (WITH GRANT OPTION)",
grantOptionHint: "Solo se aplica a las sentencias GRANT; REVOKE lo ignora.",
adminOption: "Permitir gestión de membresía (WITH ADMIN OPTION)",
grant: "Conceder",
revoke: "Revocar",
@@ -3986,6 +3989,8 @@ export default withEnglishFallback({
stepGrantAdmin: "Conceder privilegios administrativos a {user}",
stepGrantDatabase: 'Conceder a {user} acceso a la base de datos "{database}"',
stepGrantTable: 'Conceder a {user} acceso a la tabla "{database}.{table}"',
stepRevokeDatabase: 'Revocar a {user} el acceso a la base de datos "{database}"',
stepRevokeTable: 'Revocar a {user} el acceso a la tabla "{database}.{table}"',
stepGrantObjects: 'Conceder a {user} privilegios de esquema y objetos en "{database}"',
stepGrantCurrentObjects: 'Conceder a {user} privilegios sobre {scope} existentes del esquema "{schema}" en "{database}"',
stepGrantFutureObjects: 'Conceder a {user} privilegios sobre futuros {scope} creados por el rol actual en "{database}"',
+5
View File
@@ -3909,6 +3909,8 @@ export default withEnglishFallback({
noGrants: "Nessun privilegio trovato",
privilegeEditor: "Editor Privilegi",
privilegeHint: "Scegli privilegi, database e tabella, quindi visualizza l'anteprima SQL prima di applicare. * indica globale o tutte le tabelle.",
privilegeAppendHint: "Seleziona i database e specifica l'ambito delle tabelle, poi configura i privilegi per ogni database. La concessione è aggiuntiva e non revoca i privilegi esistenti.",
globalScope: "Globale (*.*)",
database: "Database",
schema: "Schema",
table: "Tabella",
@@ -3920,6 +3922,7 @@ export default withEnglishFallback({
memberRole: "Ruolo membro",
privileges: "Privilegi",
grantOption: "Consenti ulteriori concessioni (WITH GRANT OPTION)",
grantOptionHint: "Si applica solo alle istruzioni GRANT; REVOKE lo ignora.",
adminOption: "Consenti gestione appartenenza (WITH ADMIN OPTION)",
grant: "Concedi",
revoke: "Revoca",
@@ -3983,6 +3986,8 @@ export default withEnglishFallback({
stepGrantAdmin: "Concedi privilegi amministrativi a {user}",
stepGrantDatabase: 'Concedi a {user} l’accesso al database "{database}"',
stepGrantTable: 'Concedi a {user} l’accesso alla tabella "{database}.{table}"',
stepRevokeDatabase: 'Revoca a {user} l’accesso al database "{database}"',
stepRevokeTable: 'Revoca a {user} l’accesso alla tabella "{database}.{table}"',
stepGrantObjects: 'Concedi a {user} privilegi su schema e oggetti in "{database}"',
stepGrantCurrentObjects: 'Concedi a {user} i privilegi sui {scope} esistenti nello schema "{schema}" di "{database}"',
stepGrantFutureObjects: 'Concedi a {user} i privilegi sui futuri {scope} creati dal ruolo corrente in "{database}"',
+5
View File
@@ -3972,6 +3972,8 @@ export default withEnglishFallback({
noGrants: "権限が見つかりません",
privilegeEditor: "権限エディタ",
privilegeHint: "権限、データベース、テーブルを選択し、適用前にSQLをプレビューしてください。* はグローバルまたは全テーブルを意味します。",
privilegeAppendHint: "データベースを選択してテーブル範囲を指定し、データベースごとに権限を設定します。権限の付与は追加式で、既存の権限は取り消されません。",
globalScope: "グローバル (*.*)",
database: "データベース",
schema: "スキーマ",
table: "テーブル",
@@ -3983,6 +3985,7 @@ export default withEnglishFallback({
memberRole: "メンバーロール",
privileges: "権限",
grantOption: "さらなる権限付与を許可 (WITH GRANT OPTION)",
grantOptionHint: "GRANT 文にのみ適用されます(REVOKE では無視されます)。",
adminOption: "メンバーシップ管理を許可 (WITH ADMIN OPTION)",
grant: "付与",
revoke: "取り消し",
@@ -4046,6 +4049,8 @@ export default withEnglishFallback({
stepGrantAdmin: "{user} に管理権限を付与",
stepGrantDatabase: "{user} にデータベース「{database}」の権限を付与",
stepGrantTable: "{user} にテーブル「{database}.{table}」の権限を付与",
stepRevokeDatabase: "{user} のデータベース「{database}」の権限をはく奪",
stepRevokeTable: "{user} のテーブル「{database}.{table}」の権限をはく奪",
stepGrantObjects: "{user} にデータベース「{database}」のスキーマとオブジェクト権限を付与",
stepGrantCurrentObjects: "{user} にデータベース「{database}」のスキーマ「{schema}」にある既存の{scope}権限を付与",
stepGrantFutureObjects: "現在のロールがデータベース「{database}」で今後作成する{scope}の権限を {user} に付与",
+5
View File
@@ -3910,6 +3910,8 @@ export default withEnglishFallback({
noGrants: "권한이 없습니다",
privilegeEditor: "권한 편집기",
privilegeHint: "권한, 데이터베이스, 테이블을 선택한 다음 적용 전에 SQL을 미리보세요. *는 전역 또는 모든 테이블을 의미합니다.",
privilegeAppendHint: "데이터베이스를 선택하고 테이블 범위를 지정한 뒤 데이터베이스별로 권한을 설정합니다. 권한 부여는 추가 방식이며 기존 권한은 회수하지 않습니다.",
globalScope: "전역 (*.*)",
database: "데이터베이스",
schema: "스키마",
table: "테이블",
@@ -3921,6 +3923,7 @@ export default withEnglishFallback({
memberRole: "멤버 역할",
privileges: "권한",
grantOption: "추가 권한 부여 허용 (WITH GRANT OPTION)",
grantOptionHint: "GRANT 문에만 적용되며 REVOKE에는 적용되지 않습니다.",
adminOption: "멤버십 관리 허용 (WITH ADMIN OPTION)",
grant: "권한 부여",
revoke: "권한 회수",
@@ -3984,6 +3987,8 @@ export default withEnglishFallback({
stepGrantAdmin: "{user}에게 관리자 권한 부여",
stepGrantDatabase: '{user}에게 데이터베이스 "{database}" 접근 권한 부여',
stepGrantTable: '{user}에게 테이블 "{database}.{table}" 접근 권한 부여',
stepRevokeDatabase: '{user}의 데이터베이스 "{database}" 접근 권한 회수',
stepRevokeTable: '{user}의 테이블 "{database}.{table}" 접근 권한 회수',
stepGrantObjects: '"{database}"에서 {user}에게 스키마 및 객체 권한 부여',
stepGrantCurrentObjects: '"{database}"에서 "{schema}" 스키마의 현재 {scope}을(를) {user}에게 부여',
stepGrantFutureObjects: '"{database}"에서 현재 역할이 생성할 향후 {scope}을(를) {user}에게 부여',
+5
View File
@@ -3911,6 +3911,8 @@ export default withEnglishFallback({
noGrants: "Nenhuma permissão encontrada",
privilegeEditor: "Editor de Privilégios",
privilegeHint: "Escolha privilégios, banco de dados e tabela, então visualize o SQL antes de aplicar. * significa global ou todas as tabelas.",
privilegeAppendHint: "Escolha os bancos de dados e especifique o escopo das tabelas, depois configure os privilégios por banco de dados. A concessão é aditiva e não revoga privilégios existentes.",
globalScope: "Global (*.*)",
database: "Banco de dados",
schema: "Schema",
table: "Tabela",
@@ -3922,6 +3924,7 @@ export default withEnglishFallback({
memberRole: "Função membro",
privileges: "Privilégios",
grantOption: "Permitir mais concessões (WITH GRANT OPTION)",
grantOptionHint: "Aplica-se apenas às instruções GRANT; o REVOKE o ignora.",
adminOption: "Permitir gerenciamento de associação (WITH ADMIN OPTION)",
grant: "Conceder",
revoke: "Revogar",
@@ -3985,6 +3988,8 @@ export default withEnglishFallback({
stepGrantAdmin: "Conceder privilégios administrativos a {user}",
stepGrantDatabase: 'Conceder a {user} acesso ao banco de dados "{database}"',
stepGrantTable: 'Conceder a {user} acesso à tabela "{database}.{table}"',
stepRevokeDatabase: 'Revogar o acesso de {user} ao banco de dados "{database}"',
stepRevokeTable: 'Revogar o acesso de {user} à tabela "{database}.{table}"',
stepGrantObjects: 'Conceder a {user} privilégios de schema e objeto em "{database}"',
stepGrantCurrentObjects: 'Conceder a {user} privilégios sobre {scope} existentes no schema "{schema}" de "{database}"',
stepGrantFutureObjects: 'Conceder a {user} privilégios sobre futuros {scope} criados pela função atual em "{database}"',
+5
View File
@@ -3897,6 +3897,8 @@ export default withEnglishFallback({
noGrants: "Yetki bulunamadı",
privilegeEditor: "Yetki Düzenleyici",
privilegeHint: "Yetkileri, veritabanını ve tabloyu seçin, ardından uygulamadan önce SQL'i önizleyin. * genel ya da tüm tablolar anlamına gelir.",
privilegeAppendHint: "Veritabanlarını seçin ve tablo kapsamını belirleyin, ardından her veritabanı için yetkileri yapılandırın. Yetkilendirme eklemeli olarak yapılır ve mevcut yetkileri geri almaz.",
globalScope: "Genel (*.*)",
database: "Veritabanı",
schema: "Şema",
table: "Tablo",
@@ -3908,6 +3910,7 @@ export default withEnglishFallback({
memberRole: "Üye rol",
privileges: "Yetkiler",
grantOption: "Yetki devrine izin ver (WITH GRANT OPTION)",
grantOptionHint: "Yalnızca GRANT ifadeleri için geçerlidir; REVOKE bunu yok sayar.",
adminOption: "Üyelik yönetimine izin ver (WITH ADMIN OPTION)",
grant: "Yetki ver",
revoke: "Yetkiyi geri al",
@@ -3972,6 +3975,8 @@ export default withEnglishFallback({
stepGrantAdmin: "{user} kullanıcısına yönetici yetkileri ver",
stepGrantDatabase: '{user} kullanıcısına "{database}" veritabanına erişim ver',
stepGrantTable: '{user} kullanıcısına "{database}.{table}" tablosuna erişim ver',
stepRevokeDatabase: '{user} kullanıcısının "{database}" veritabanına erişimini geri al',
stepRevokeTable: '{user} kullanıcısının "{database}.{table}" tablosuna erişimini geri al',
stepGrantObjects: '{user} kullanıcısına "{database}" içinde şema ve nesne yetkileri ver',
stepGrantCurrentObjects: '"{database}" içindeki "{schema}" şemasında mevcut {scope} üzerinde {user} kullanıcısına yetki ver',
stepGrantFutureObjects: '"{database}" içinde geçerli rolün ileride oluşturacağı {scope} üzerinde {user} kullanıcısına yetki ver',
+5
View File
@@ -4191,6 +4191,8 @@ export default withEnglishFallback({
noGrants: "暂无授权信息",
privilegeEditor: "权限编辑",
privilegeHint: "选择权限、数据库和表,预览 SQL 后执行。* 表示全局或全部表。",
privilegeAppendHint: "选择数据库并指定表范围,按每个数据库配置权限;授权为追加式,不会回收该用户已有的权限。",
globalScope: "全局 (*.*)",
database: "数据库",
schema: "Schema",
table: "表",
@@ -4202,6 +4204,7 @@ export default withEnglishFallback({
memberRole: "成员角色",
privileges: "权限",
grantOption: "允许继续授权 (WITH GRANT OPTION)",
grantOptionHint: "仅对授权语句生效,撤权时不生效。",
adminOption: "允许管理成员关系 (WITH ADMIN OPTION)",
grant: "授权",
revoke: "撤权",
@@ -4265,6 +4268,8 @@ export default withEnglishFallback({
stepGrantAdmin: "为 {user} 授予管理权限",
stepGrantDatabase: "为 {user} 授予数据库「{database}」权限",
stepGrantTable: "为 {user} 授予表「{database}.{table}」权限",
stepRevokeDatabase: "回收 {user} 在数据库「{database}」的权限",
stepRevokeTable: "回收 {user} 在表「{database}.{table}」的权限",
stepGrantObjects: "为 {user} 授予数据库「{database}」的 Schema 与对象权限",
stepGrantCurrentObjects: "为 {user} 授予数据库「{database}」中 Schema「{schema}」的现有{scope}权限",
stepGrantFutureObjects: "为 {user} 授予当前角色在数据库「{database}」中未来创建的{scope}权限",
+5
View File
@@ -7950,6 +7950,8 @@ export default withEnglishFallback({
noGrants: "未找到授權",
privilegeEditor: "權限編輯器",
privilegeHint: "選擇權限、資料庫和資料表,然後在套用前預覽 SQL。* 表示全域或所有資料表。",
privilegeAppendHint: "選擇資料庫並指定資料表範圍,為每個資料庫個別設定權限;授權為累加式,不會回收該使用者既有的權限。",
globalScope: "全域 (*.*)",
database: "資料庫",
schema: "Schema",
table: "資料表",
@@ -7961,6 +7963,7 @@ export default withEnglishFallback({
memberRole: "成員角色",
privileges: "權限",
grantOption: "允許進一步授權(WITH GRANT OPTION)",
grantOptionHint: "僅對授權語句生效,撤權時不生效。",
adminOption: "允許成員管理(WITH ADMIN OPTION)",
grant: "授予",
revoke: "撤銷",
@@ -8024,6 +8027,8 @@ export default withEnglishFallback({
stepGrantAdmin: "為 {user} 授予管理權限",
stepGrantDatabase: "為 {user} 授予資料庫「{database}」權限",
stepGrantTable: "為 {user} 授予資料表「{database}.{table}」權限",
stepRevokeDatabase: "回收 {user} 在資料庫「{database}」的權限",
stepRevokeTable: "回收 {user} 在資料表「{database}.{table}」的權限",
stepGrantObjects: "為 {user} 授予資料庫「{database}」的 Schema 與物件權限",
stepGrantCurrentObjects: "為 {user} 授予資料庫「{database}」中 Schema「{schema}」的既有{scope}權限",
stepGrantFutureObjects: "為 {user} 授予目前角色在資料庫「{database}」中未來建立的{scope}權限",
@@ -42,7 +42,7 @@ function modelDialog(): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs, slots }) {
return () => (attrs.open ? h("div", attrs, slots.default?.()) : null);
return () => (attrs.open ? h("div", { ...attrs, "data-test-dialog": "true" }, slots.default?.()) : null);
},
});
}
@@ -163,7 +163,7 @@ afterEach(() => {
});
describe("DatabaseUserAdmin MySQL grant loading", () => {
it("syncs privilege buttons and grant option from loaded SHOW GRANTS rows", async () => {
it("syncs the grant option from loaded SHOW GRANTS rows and renders the database scope editor", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "mysql_native_password"]] }).mockResolvedValueOnce({ columns: ["Grants for root@%"], rows: [["GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION"]] });
@@ -175,12 +175,12 @@ describe("DatabaseUserAdmin MySQL grant loading", () => {
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
await nextTick();
const privilegeButton = Array.from(root.querySelectorAll("button")).find((button) => button.textContent?.trim() === "INSERT");
const grantOptionLabel = Array.from(root.querySelectorAll("label")).find((label) => label.textContent?.includes("userAdmin.grantOption"));
const grantOptionInput = grantOptionLabel?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(privilegeButton?.className).toContain("border-primary");
expect(grantOptionInput?.checked).toBe(true);
// MySQL 的权限编辑改为按数据库/表配置授权范围(与新增用户一致),固定的权限按钮由范围编辑器取代
expect(root.querySelector('input[placeholder="userAdmin.searchDatabase"]')).not.toBeNull();
});
it("falls back to the current Doris user when SHOW ALL GRANTS requires GRANT_PRIV", async () => {
@@ -219,6 +219,12 @@ function findButton(text: string): HTMLButtonElement | undefined {
return Array.from(root?.querySelectorAll("button") ?? []).find((button) => button.textContent?.trim() === text);
}
// 权限编辑面板与新增用户弹窗复用同一个授权范围编辑器,断言弹窗内的控件时需要限定在弹窗范围内
function findDialogButton(text: string): HTMLButtonElement | undefined {
const dialog = root?.querySelector('[data-test-dialog="true"]');
return Array.from(dialog?.querySelectorAll("button") ?? []).find((button) => button.textContent?.trim() === text);
}
describe("DatabaseUserAdmin MySQL create-user table grants", () => {
it("loads tables, requires a selection, and previews only the selected table grant", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
@@ -237,22 +243,22 @@ describe("DatabaseUserAdmin MySQL create-user table grants", () => {
findButton("userAdmin.newUser")?.click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("native-mysql"));
await nextTick();
findButton("scope_test")?.click();
await vi.waitFor(() => expect(findDialogButton("scope_test")).toBeDefined());
findDialogButton("scope_test")?.click();
const password = root.querySelector<HTMLInputElement>('[data-password-input="true"]');
password!.value = "test-password";
password!.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
findButton("userAdmin.specificTables")?.click();
findDialogButton("userAdmin.specificTables")?.click();
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalledWith("native-mysql", "scope_test", ""));
await nextTick();
expect(findButton("userAdmin.previewSql")?.disabled).toBe(true);
expect(findDialogButton("userAdmin.previewSql")?.disabled).toBe(true);
findButton("allowed_table")?.click();
findDialogButton("allowed_table")?.click();
await nextTick();
expect(findButton("userAdmin.previewSql")?.disabled).toBe(false);
findButton("userAdmin.previewSql")?.click();
expect(findDialogButton("userAdmin.previewSql")?.disabled).toBe(false);
findDialogButton("userAdmin.previewSql")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`allowed_table` TO 'app_user'@'%';"));
expect(root.textContent).not.toContain("ON `scope_test`.*");
@@ -260,6 +266,65 @@ describe("DatabaseUserAdmin MySQL create-user table grants", () => {
});
});
describe("DatabaseUserAdmin MySQL privilege editing", () => {
it("previews one grant per selected table for the selected user", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'root'@'%'"]] });
mocks.listDatabases.mockResolvedValue([{ name: "scope_test" }]);
mocks.listTables.mockResolvedValue([
{ name: "allowed_table", table_type: "BASE TABLE" },
{ name: "blocked_table", table_type: "BASE TABLE" },
]);
root = document.createElement("div");
document.body.append(root);
app = createApp(DatabaseUserAdmin, { connection: nativeMysqlConnection });
app.mount(root);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(findButton("scope_test")).toBeDefined());
findButton("scope_test")?.click();
await nextTick();
findButton("userAdmin.specificTables")?.click();
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalledWith("native-mysql", "scope_test", ""));
await nextTick();
findButton("allowed_table")?.click();
findButton("blocked_table")?.click();
await nextTick();
findButton("userAdmin.grant")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`allowed_table` TO 'root'@'%';"));
expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON `scope_test`.`blocked_table` TO 'root'@'%';");
});
});
describe("DatabaseUserAdmin MySQL global scope", () => {
it("grants the global scope through the fixed entry and hides table scope", async () => {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'root'@'%'"]] });
mocks.listDatabases.mockResolvedValue([{ name: "scope_test" }]);
root = document.createElement("div");
document.body.append(root);
app = createApp(DatabaseUserAdmin, { connection: nativeMysqlConnection });
app.mount(root);
await vi.waitFor(() => expect(mocks.executeQuery).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(findButton("userAdmin.globalScope")).toBeDefined());
findButton("userAdmin.globalScope")?.click();
await nextTick();
// 全局作用域没有表级粒度,不应出现表范围控件
expect(findButton("userAdmin.specificTables")).toBeUndefined();
findButton("userAdmin.grant")?.click();
await vi.waitFor(() => expect(root?.textContent).toContain("GRANT SELECT, SHOW VIEW ON *.* TO 'root'@'%';"));
expect(root?.textContent).not.toContain("ON `scope_test`.*");
});
});
async function mountNativeMysqlUserAdmin() {
mocks.ensureConnected.mockResolvedValue(undefined);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["same-user", "old-host", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT SELECT ON *.* TO 'same-user'@'old-host'"]] });
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { mysqlUserAdminProvider, postgresUserAdminProvider } from "@/lib/database/databaseUserAdmin";
import { authorizationPlanSql, buildCreateDatabaseAuthorizationPlan, buildCreateUserAuthorizationPlan, executeAuthorizationPlan } from "@/lib/database/databaseAuthorizationPlan";
import { dorisUserAdminProvider, mysqlUserAdminProvider, postgresUserAdminProvider, starrocksUserAdminProvider } from "@/lib/database/databaseUserAdmin";
import { authorizationPlanSql, buildCreateDatabaseAuthorizationPlan, buildCreateUserAuthorizationPlan, buildGrantAuthorizationPlan, executeAuthorizationPlan } from "@/lib/database/databaseAuthorizationPlan";
describe("database authorization plans", () => {
it("grants PostgreSQL presets across user schemas and future objects", () => {
@@ -184,4 +184,55 @@ describe("database authorization plans", () => {
expect(results.map((result) => result.status)).toEqual(["failed", "skipped"]);
});
it("expands an existing user's privilege change into one statement per selected table", () => {
const plan = buildGrantAuthorizationPlan({
provider: mysqlUserAdminProvider,
user: { user: "app_user", host: "%" },
databases: [{ database: "app_db", preset: "readOnly", tables: ["orders", "audit_log"] }],
grantOption: true,
});
const sql = authorizationPlanSql(plan);
expect(plan.steps).toHaveLength(2);
expect(plan.steps.map((step) => step.operation)).toEqual(["grantDatabase", "grantDatabase"]);
expect(plan.steps.map((step) => step.targetTable)).toEqual(["orders", "audit_log"]);
expect(sql).toContain("GRANT SELECT, SHOW VIEW ON `app_db`.`orders` TO 'app_user'@'%' WITH GRANT OPTION;");
expect(sql).toContain("GRANT SELECT, SHOW VIEW ON `app_db`.`audit_log` TO 'app_user'@'%' WITH GRANT OPTION;");
expect(sql).not.toContain("CREATE ROUTINE");
});
it("keeps the global scope as *.* for grant and revoke", () => {
const selected = [{ database: "*", preset: "readOnly" as const }];
const grant = buildGrantAuthorizationPlan({ provider: mysqlUserAdminProvider, user: { user: "local_admin", host: "%" }, databases: selected });
const revoke = buildGrantAuthorizationPlan({ provider: mysqlUserAdminProvider, user: { user: "local_admin", host: "%" }, databases: selected, revoke: true });
expect(authorizationPlanSql(grant)).toContain("GRANT SELECT, SHOW VIEW ON *.* TO 'local_admin'@'%';");
expect(authorizationPlanSql(revoke)).toContain("REVOKE SELECT, SHOW VIEW ON *.* FROM 'local_admin'@'%';");
});
it("builds REVOKE steps without grant options", () => {
const plan = buildGrantAuthorizationPlan({
provider: mysqlUserAdminProvider,
user: { user: "app_user", host: "%" },
databases: [{ database: "app_db", preset: "readWrite", tables: ["orders"] }],
grantOption: true,
revoke: true,
});
expect(plan.steps.map((step) => step.operation)).toEqual(["revokePrivileges"]);
expect(plan.steps[0].targetTable).toBe("orders");
expect(authorizationPlanSql(plan)).toBe("-- revoke app_user@% app_db.orders (connection scope)\nREVOKE SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, REFERENCES, SHOW VIEW, CREATE VIEW, TRIGGER ON `app_db`.`orders` FROM 'app_user'@'%';");
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" }] });
expect(plan.steps).toEqual([]);
}
});
});
@@ -4,7 +4,7 @@ import { mysqlUserAccount, quoteMySqlIdentifier, quotePostgresIdentifier, type C
export type AuthorizationAccountType = "standard" | "admin";
export type AuthorizationPreset = "readWrite" | "readOnly" | "ddl" | "dml" | "custom";
export type AuthorizationTargetScope = "database" | "table";
export type AuthorizationStepOperation = "createUser" | "grantAdmin" | "createDatabase" | "grantDatabase" | "grantCurrentObjects" | "grantFutureObjects";
export type AuthorizationStepOperation = "createUser" | "grantAdmin" | "createDatabase" | "grantDatabase" | "revokePrivileges" | "grantCurrentObjects" | "grantFutureObjects";
export type AuthorizationObjectScope = "schemas" | "tables" | "sequences" | "functions";
export interface DatabaseAuthorizationSelection {
@@ -149,6 +149,57 @@ export function buildCreateUserAuthorizationPlan(input: CreateUserAuthorizationP
return { steps };
}
export interface GrantAuthorizationPlanInput {
provider: DatabaseUserAdminProvider;
/** 目标用户身份 */
user: DatabaseUserIdentity;
/** 需要授权(或撤权)的库/表选择 */
databases: DatabaseAuthorizationSelection[];
/** 是否携带 WITH GRANT OPTION,仅对授权语句生效 */
grantOption?: boolean;
/** 为 true 时生成 REVOKE,默认生成 GRANT */
revoke?: boolean;
}
/**
* 为“已存在的用户”构造授权计划:把多库/多表选择展开为多条独立的 GRANT / REVOKE。
* 与 buildCreateUserAuthorizationPlan 的差异:
* 1. 不包含创建用户步骤,因此各步骤之间没有依赖关系;
* 2. 采用追加式语义,只对勾选的库/表生成语句,不做差异比对,也不会回收用户已有的多余权限;
* 3. 目前仅用于 MySQL 表级/库级授权,其它方言返回空计划,由调用方沿用原有的单条 SQL 逻辑。
*/
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 ?? []);
for (const table of tables) {
const targetTable = table === "*" ? undefined : 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" }),
operation: input.revoke ? "revokePrivileges" : "grantDatabase",
subject: input.provider.label(input.user),
targetDatabase: database,
targetTable,
});
}
}
return { steps };
}
export function buildCreateDatabaseAuthorizationPlan(input: CreateDatabaseAuthorizationPlanInput): AuthorizationPlan {
const createStepId = "create-database";
const steps: AuthorizationPlanStep[] = [