fix(user-admin): sync MySQL grants to privilege editor

This commit is contained in:
t8y2
2026-07-21 18:05:10 +08:00
parent d221e0dee7
commit b42f2cc470
4 changed files with 297 additions and 3 deletions
@@ -40,6 +40,7 @@ const { highlight } = useSqlHighlighter();
const users = ref<DatabaseUserIdentity[]>([]);
const selectedUserKey = ref("");
const grants = ref<string[]>([]);
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);
</script>
@@ -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<typeof createApp> | 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<HTMLInputElement>('input[type="checkbox"]');
expect(privilegeButton?.className).toContain("border-primary");
expect(grantOptionInput?.checked).toBe(true);
});
});
@@ -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");
@@ -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<DatabaseType>(["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<string>();
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 = {