mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 12:41:39 +00:00
fix(user-admin): sync MySQL grants to privilege editor
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user