From deaa867498258c1ab4484b21eb7eea8e6638ae50 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 27 Sep 2026 03:17:25 +0000 Subject: [PATCH] feat(admin): add table grants for postgres and starrocks Closes #10251 --- .../admin/AuthorizationScopeEditor.vue | 186 ++++++++---- .../components/admin/DatabaseUserAdmin.vue | 229 +++++++++++--- .../DatabaseUserAdmin.component.spec.ts | 163 ++++++++++ .../databaseAuthorizationPlan.spec.ts | 148 ++++++++- .../database/databaseUserAdmin.spec.ts | 55 +++- .../lib/database/databaseAuthorizationPlan.ts | 282 ++++++++++++++++-- .../src/lib/database/databaseUserAdmin.ts | 194 +++++++++++- 7 files changed, 1106 insertions(+), 151 deletions(-) diff --git a/apps/desktop/src/components/admin/AuthorizationScopeEditor.vue b/apps/desktop/src/components/admin/AuthorizationScopeEditor.vue index 6aedf794d3..b9743c46a7 100644 --- a/apps/desktop/src/components/admin/AuthorizationScopeEditor.vue +++ b/apps/desktop/src/components/admin/AuthorizationScopeEditor.vue @@ -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>({}); +const tables = ref>({}); const tablesLoading = ref>({}); const tableErrors = ref>({}); const tableSearch = ref>({}); @@ -47,12 +48,12 @@ const tableSearch = ref>({}); 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): 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(); + 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_"); +}