diff --git a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue index 12ccdbde57..8299e7b880 100644 --- a/apps/desktop/src/components/admin/DatabaseUserAdmin.vue +++ b/apps/desktop/src/components/admin/DatabaseUserAdmin.vue @@ -40,6 +40,7 @@ const { highlight } = useSqlHighlighter(); const users = ref([]); const selectedUserKey = ref(""); const grants = ref([]); +const grantsLoaded = ref(false); const search = ref(""); const loadingUsers = ref(false); const loadingGrants = ref(false); @@ -113,6 +114,19 @@ const selectedCreateDatabaseSet = computed(() => new Set(createDatabaseAuthoriza const createDatabaseAuthorizationsValid = computed(() => createDatabaseAuthorizations.value.every((selection) => selection.preset !== "custom" || (selection.privileges?.length ?? 0) > 0)); const pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined)); +function syncPrivilegeSelectionFromGrants() { + const selectionFromGrants = provider.value?.privilegeSelectionFromGrants; + if (!grantsLoaded.value || !selectionFromGrants) return; + const selection = selectionFromGrants({ + grants: grants.value, + database: privilegeDatabase.value, + table: privilegeTable.value, + availablePrivileges: availablePrivileges.value, + }); + selectedPrivileges.value = selection.privileges; + grantOption.value = selection.grantOption; +} + function userKey(user: DatabaseUserIdentity): string { return `${user.user}\u0000${user.host}`; } @@ -163,18 +177,24 @@ async function loadGrants() { const userProvider = provider.value; if (!user || !userProvider) { grants.value = []; + grantsLoaded.value = false; return; } loadingGrants.value = true; + grantsLoaded.value = false; grantError.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; + // Existing-user editing reflects the exact SHOW GRANTS scope; create-user defaults stay independent. + syncPrivilegeSelectionFromGrants(); } catch (error: any) { grantError.value = error?.message || String(error); grants.value = []; + grantsLoaded.value = false; } finally { loadingGrants.value = false; } @@ -448,7 +468,10 @@ watch( watch( () => selectedUserKey.value, - () => void loadGrants(), + () => { + grantsLoaded.value = false; + void loadGrants(); + }, ); watch( @@ -459,6 +482,7 @@ watch( createDatabaseAuthorizations.value = []; selectedUserKey.value = ""; grants.value = []; + grantsLoaded.value = false; privilegeScope.value = provider.value?.defaultScope ?? "mysql"; resetPrivilegeDefaults(privilegeScope.value); void loadUsers(); @@ -476,9 +500,14 @@ watch( watch( () => privilegeScope.value, - (scope) => resetPrivilegeDefaults(scope), + (scope) => { + resetPrivilegeDefaults(scope); + syncPrivilegeSelectionFromGrants(); + }, ); +watch([privilegeDatabase, privilegeTable], syncPrivilegeSelectionFromGrants); + onMounted(loadUsers); diff --git a/apps/desktop/src/lib/__tests__/database/DatabaseUserAdmin.component.spec.ts b/apps/desktop/src/lib/__tests__/database/DatabaseUserAdmin.component.spec.ts new file mode 100644 index 0000000000..b5973c3c20 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/DatabaseUserAdmin.component.spec.ts @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type Component } from "vue"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ConnectionConfig } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + ensureConnected: vi.fn(), + executeQuery: vi.fn(), +})); + +function passthrough(tag: string): Component { + return defineComponent({ + inheritAttrs: false, + setup(_, { attrs, slots }) { + return () => h(tag, attrs, slots.default?.()); + }, + }); +} + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock("@lucide/vue", () => { + const Icon = passthrough("span"); + return { + AlertTriangle: Icon, + Check: Icon, + KeyRound: Icon, + Lock: Icon, + Loader2: Icon, + Plus: Icon, + RefreshCcw: Icon, + Search: Icon, + ShieldCheck: Icon, + Trash2: Icon, + Unlock: Icon, + UserRound: Icon, + }; +}); +vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") })); +vi.mock("@/components/ui/badge", () => ({ Badge: passthrough("span") })); +vi.mock("@/components/ui/dialog", () => ({ + Dialog: passthrough("div"), + DialogContent: passthrough("div"), + DialogFooter: passthrough("div"), + DialogHeader: passthrough("div"), + DialogTitle: passthrough("div"), +})); +vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") })); +vi.mock("@/components/ui/PasswordInput.vue", () => ({ default: passthrough("input") })); +vi.mock("@/components/ui/select", () => ({ + Select: passthrough("div"), + SelectContent: passthrough("div"), + SelectItem: passthrough("div"), + SelectTrigger: passthrough("div"), + SelectValue: passthrough("span"), +})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ ensureConnected: mocks.ensureConnected }), +})); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) })); +vi.mock("@/lib/backend/api", () => ({ + executeQuery: mocks.executeQuery, + executeMulti: vi.fn(), + listDatabases: vi.fn(), + listSchemas: vi.fn(), +})); + +import DatabaseUserAdmin from "@/components/admin/DatabaseUserAdmin.vue"; + +const connection: ConnectionConfig = { + id: "oceanbase", + name: "OceanBase", + db_type: "jdbc", + driver_profile: "mysql", + host: "localhost", + port: 2881, + username: "root", + password: "", +}; + +let app: ReturnType | undefined; +let root: HTMLDivElement | undefined; + +afterEach(() => { + app?.unmount(); + root?.remove(); + app = undefined; + root = undefined; + vi.clearAllMocks(); +}); + +describe("DatabaseUserAdmin MySQL grant loading", () => { + it("syncs privilege buttons and grant option from loaded SHOW GRANTS rows", 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"]] }); + + root = document.createElement("div"); + document.body.append(root); + app = createApp(DatabaseUserAdmin, { connection }); + app.mount(root); + + 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('input[type="checkbox"]'); + + expect(privilegeButton?.className).toContain("border-primary"); + expect(grantOptionInput?.checked).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/database/databaseUserAdmin.spec.ts b/apps/desktop/src/lib/__tests__/database/databaseUserAdmin.spec.ts index 7ed58623bf..0b0aa1ad4c 100644 --- a/apps/desktop/src/lib/__tests__/database/databaseUserAdmin.spec.ts +++ b/apps/desktop/src/lib/__tests__/database/databaseUserAdmin.spec.ts @@ -1,7 +1,75 @@ import { describe, expect, it } from "vitest"; -import { getDatabaseUserAdminProvider, kingbaseShowGrantsSql } from "@/lib/database/databaseUserAdmin"; +import { getDatabaseUserAdminProvider, kingbaseShowGrantsSql, mysqlPrivilegeSelectionFromGrants } from "@/lib/database/databaseUserAdmin"; + +describe("MySQL grant privilege selection", () => { + const availablePrivileges = ["SELECT", "INSERT", "UPDATE", "EXECUTE"]; + + it("expands ALL PRIVILEGES and reads WITH GRANT OPTION for the current scope", () => { + expect( + mysqlPrivilegeSelectionFromGrants({ + grants: ["grant all privileges on `*`.* to 'root'@'%' WITH GRANT OPTION"], + database: "*", + table: "*", + availablePrivileges, + }), + ).toEqual({ privileges: availablePrivileges, grantOption: true }); + }); + + it("merges matching grants case-insensitively", () => { + expect( + mysqlPrivilegeSelectionFromGrants({ + grants: ["GRANT SELECT, insert ON `Sales`.`Orders` TO 'app'@'%'", "GRANT UPDATE ON sales.orders TO 'app'@'%' WITH GRANT OPTION"], + database: "SALES", + table: "orders", + availablePrivileges, + }), + ).toEqual({ privileges: ["SELECT", "INSERT", "UPDATE"], grantOption: true }); + }); + + it("supports escaped backticks and ignores grants for other scopes or routines", () => { + expect( + mysqlPrivilegeSelectionFromGrants({ + grants: ["GRANT SELECT ON `tenant``db`.`audit``log` TO 'app'@'%'", "GRANT INSERT ON `tenant``db`.* TO 'app'@'%' WITH GRANT OPTION", "GRANT EXECUTE ON PROCEDURE `tenant``db`.`audit``log` TO 'app'@'%' WITH GRANT OPTION"], + database: "tenant`db", + table: "audit`log", + availablePrivileges, + }), + ).toEqual({ privileges: ["SELECT"], grantOption: false }); + }); + + it("returns an empty selection when the current scope has no grant", () => { + expect( + mysqlPrivilegeSelectionFromGrants({ + grants: ["GRANT SELECT ON `other`.* TO 'app'@'%' WITH GRANT OPTION"], + database: "current", + table: "*", + availablePrivileges, + }), + ).toEqual({ privileges: [], grantOption: false }); + }); +}); describe("database user admin providers", () => { + it("syncs loaded grants only for the MySQL provider", () => { + const mysqlProvider = getDatabaseUserAdminProvider("mysql"); + const postgresProvider = getDatabaseUserAdminProvider("postgres"); + const starrocksProvider = getDatabaseUserAdminProvider("starrocks"); + + expect( + mysqlProvider?.privilegeSelectionFromGrants?.({ + grants: ["GRANT INSERT ON `app`.* TO 'user'@'%'"], + database: "app", + table: "*", + availablePrivileges: mysqlProvider.privilegesForScope?.("mysql") ?? [], + }), + ).toEqual({ privileges: ["INSERT"], grantOption: false }); + expect(postgresProvider?.privilegeSelectionFromGrants).toBeUndefined(); + expect(starrocksProvider?.privilegeSelectionFromGrants).toBeUndefined(); + expect(mysqlProvider?.defaultPrivilegesForScope?.("mysql")).toEqual(["SELECT"]); + expect(postgresProvider?.defaultPrivilegesForScope?.("database")).toEqual(["CONNECT"]); + expect(starrocksProvider?.defaultPrivilegesForScope?.("table")).toEqual(["SELECT"]); + }); + it("uses sys_catalog for Kingbase role metadata", () => { const provider = getDatabaseUserAdminProvider("kingbase"); diff --git a/apps/desktop/src/lib/database/databaseUserAdmin.ts b/apps/desktop/src/lib/database/databaseUserAdmin.ts index a8cfd61fac..83bfc46b9b 100644 --- a/apps/desktop/src/lib/database/databaseUserAdmin.ts +++ b/apps/desktop/src/lib/database/databaseUserAdmin.ts @@ -26,6 +26,18 @@ export interface PrivilegeChangeInput { role?: string; } +export interface PrivilegeSelectionInput { + grants: readonly string[]; + database: string; + table?: string; + availablePrivileges: readonly string[]; +} + +export interface PrivilegeSelection { + privileges: string[]; + grantOption: boolean; +} + export interface DatabaseUserAdminProvider { dialect: UserAdminDialect; defaultScope: PrivilegeScope; @@ -45,6 +57,7 @@ export interface DatabaseUserAdminProvider { detail(user: DatabaseUserIdentity): string | undefined; privilegesForScope?(scope: PrivilegeScope): readonly string[]; defaultPrivilegesForScope?(scope: PrivilegeScope): string[]; + privilegeSelectionFromGrants?(input: PrivilegeSelectionInput): PrivilegeSelection; } export const MYSQL_USER_ADMIN_TYPES = new Set(["mysql", "goldendb"]); @@ -180,6 +193,35 @@ export function normalizePrivileges(privileges: string[], fallback = "SELECT"): export const normalizeMySqlPrivileges = normalizePrivileges; +export function mysqlPrivilegeSelectionFromGrants(input: PrivilegeSelectionInput): PrivilegeSelection { + const database = normalizeMySqlScopeIdentifier(input.database || "*"); + const table = normalizeMySqlScopeIdentifier(input.table || "*"); + const availableByName = new Map(input.availablePrivileges.map((privilege) => [normalizePrivilegeName(privilege), privilege])); + const selected = new Set(); + let grantOption = false; + + for (const grantSql of input.grants) { + const grant = parseMySqlGrant(grantSql); + if (!grant || grant.objectType === "FUNCTION" || grant.objectType === "PROCEDURE") continue; + if (normalizeMySqlScopeIdentifier(grant.database) !== database || normalizeMySqlScopeIdentifier(grant.table) !== table) continue; + + grantOption ||= grant.grantOption; + if (grant.allPrivileges) { + input.availablePrivileges.forEach((privilege) => selected.add(privilege)); + continue; + } + grant.privileges.forEach((privilege) => { + const available = availableByName.get(normalizePrivilegeName(privilege)); + if (available) selected.add(available); + }); + } + + return { + privileges: input.availablePrivileges.filter((privilege) => selected.has(privilege)), + grantOption, + }; +} + export function usersFromMySqlUserResult(result: QueryResult): DatabaseUserIdentity[] { const userIndex = columnIndex(result, "user", "User"); const hostIndex = columnIndex(result, "host", "Host"); @@ -437,6 +479,47 @@ function parseMySqlGrantee(value: string): DatabaseUserIdentity | null { }; } +interface ParsedMySqlGrant { + privileges: string[]; + database: string; + table: string; + objectType?: "TABLE" | "FUNCTION" | "PROCEDURE"; + allPrivileges: boolean; + grantOption: boolean; +} + +function parseMySqlGrant(sql: string): ParsedMySqlGrant | null { + const identifier = String.raw`(?:\x60(?:\x60\x60|[^\x60])*\x60|\*|[^\s.]+)`; + const match = new RegExp(String.raw`^\s*GRANT\s+(.+?)\s+ON\s+(?:(TABLE|FUNCTION|PROCEDURE)\s+)?(${identifier})\s*\.\s*(${identifier})\s+TO\s+`, "i").exec(sql); + if (!match) return null; + + const privileges = match[1].split(",").map(normalizePrivilegeName).filter(Boolean); + return { + privileges, + database: unquoteMySqlIdentifier(match[3]), + table: unquoteMySqlIdentifier(match[4]), + objectType: match[2]?.toUpperCase() as ParsedMySqlGrant["objectType"], + allPrivileges: privileges.some((privilege) => privilege === "ALL" || privilege === "ALL PRIVILEGES"), + grantOption: /\s+WITH\s+GRANT\s+OPTION\s*;?\s*$/i.test(sql), + }; +} + +function normalizePrivilegeName(value: string): string { + return value.trim().replace(/\s+/g, " ").toUpperCase(); +} + +function normalizeMySqlScopeIdentifier(value: string): string { + return unquoteMySqlIdentifier(value.trim()).toLocaleLowerCase("en-US"); +} + +function unquoteMySqlIdentifier(value: string): string { + const trimmed = value.trim(); + if (trimmed.length >= 2 && trimmed.startsWith("`") && trimmed.endsWith("`")) { + return trimmed.slice(1, -1).replace(/``/g, "`"); + } + return trimmed; +} + function postgresDefaultPrivilege(scope: PrivilegeScope | undefined): string { if (scope === "schema") return "USAGE"; if (scope === "database") return "CONNECT"; @@ -472,6 +555,7 @@ export const mysqlUserAdminProvider: DatabaseUserAdminProvider = { detail: (user) => user.plugin, privilegesForScope: () => MYSQL_COMMON_PRIVILEGES, defaultPrivilegesForScope: () => ["SELECT"], + privilegeSelectionFromGrants: mysqlPrivilegeSelectionFromGrants, }; export const postgresUserAdminProvider: DatabaseUserAdminProvider = {