feat(mysql): support table-scoped grants for new users

This commit is contained in:
zipg authored and GitHub committed 2026-08-18 14:50:48 +08:00
1 parent 2eee29c0bf
commit 61103523de
13 files changed
+407 -24

No files matched your search

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, Check, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Unlock, UserRound } from "@lucide/vue";
import { AlertTriangle, Check, ChevronDown, Globe2, KeyRound, Lock, Loader2, Plus, RefreshCcw, Search, ShieldCheck, Table2, 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 { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@@ -77,6 +78,10 @@ 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>>({});
let createPlanRequestId = 0;
const provider = computed(() => resolveDatabaseUserAdminProviderForConnection(props.connection));
@@ -90,6 +95,7 @@ const canDropUser = computed(() => !!provider.value?.dropUserSql);
const canGrantPrivileges = computed(() => !!provider.value?.grantPrivilegesSql);
const canRevokePrivileges = computed(() => !!provider.value?.revokePrivilegesSql);
const canEditPrivileges = computed(() => canGrantPrivileges.value || canRevokePrivileges.value);
const supportsCreateTableGrants = computed(() => !!provider.value?.supportsTableGrantsOnCreate);
const selectedUser = computed(() => users.value.find((user) => userKey(user) === selectedUserKey.value));
const filteredUsers = computed(() => {
const query = search.value.trim().toLowerCase();
@@ -109,13 +115,18 @@ 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 createAuthorizationPrivileges = computed(() => (provider.value ? authorizationPrivileges(provider.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) => selection.preset !== "custom" || (selection.privileges?.length ?? 0) > 0));
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 pendingStatus = computed(() => (pendingResults.value.length > 0 ? authorizationPlanStatus(pendingResults.value) : undefined));
const canPreviewHostChange = computed(() => {
const host = newHost.value.trim();
@@ -233,6 +244,10 @@ async function openCreateUserDialog() {
createPassword.value = "";
createDatabaseSearch.value = "";
createDatabaseAuthorizations.value = [];
createDatabaseTables.value = {};
createDatabaseTablesLoading.value = {};
createDatabaseTableErrors.value = {};
createDatabaseTableSearch.value = {};
if (createDatabasesLoading.value) return;
createDatabases.value = [];
createDatabasesLoading.value = true;
@@ -260,6 +275,82 @@ function toggleCreateDatabase(database: string) {
}
}
function createPrivilegesForAuthorization(selection?: DatabaseAuthorizationSelection): string[] {
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;
@@ -371,7 +462,9 @@ function authorizationStepLabel(result: AuthorizationStepResult): string {
const step = result.step;
if (step.operation === "createUser") return t("userAdmin.stepCreateUser");
if (step.operation === "grantAdmin") return t("userAdmin.stepGrantAdmin", { user: step.subject });
if (step.operation === "grantDatabase") return t("userAdmin.stepGrantDatabase", { user: step.subject, database: step.targetDatabase });
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 === "grantCurrentObjects") {
return t("userAdmin.stepGrantCurrentObjects", {
user: step.subject,
@@ -511,6 +604,10 @@ watch(
users.value = [];
createDatabases.value = [];
createDatabaseAuthorizations.value = [];
createDatabaseTables.value = {};
createDatabaseTablesLoading.value = {};
createDatabaseTableErrors.value = {};
createDatabaseTableSearch.value = {};
selectedUserKey.value = "";
grants.value = [];
grantsLoaded.value = false;
@@ -794,9 +891,65 @@ onMounted(loadUsers);
</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 createAuthorizationPrivileges"
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]"
+14
View File
@@ -3191,6 +3191,19 @@ export default {
adminUserWarning: "This account can manage databases and other users. Review the complete SQL carefully before applying it.",
databaseAccess: "Database access",
databaseAccessHint: "Select databases and configure a separate permission level for each one.",
tableScope: "Table scope",
allTables: "All tables",
specificTables: "Specific tables",
chooseTables: "Choose tables",
selectedTableCount: "{count} tables selected",
searchTable: "Search tables",
loadingTables: "Loading tables...",
emptyTables: "No tables found",
loadTablesFailed: "Could not load tables: {message}",
tableSelectionRequired: "Select at least one table.",
selectAllTables: "Select all",
clearAllTables: "Clear",
retry: "Retry",
selectedDatabaseCount: "{count} databases",
searchDatabase: "Search databases",
loadingDatabases: "Loading databases...",
@@ -3211,6 +3224,7 @@ export default {
stepCreateUser: "Create user or role",
stepGrantAdmin: "Grant administrative privileges to {user}",
stepGrantDatabase: 'Grant {user} access to database "{database}"',
stepGrantTable: 'Grant {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}"',
+14
View File
@@ -3030,6 +3030,19 @@ export default withEnglishFallback({
adminUserWarning: "Esta cuenta puede administrar bases de datos y otros usuarios. Revisa el SQL completo antes de aplicarlo.",
databaseAccess: "Acceso a bases de datos",
databaseAccessHint: "Selecciona bases de datos y configura un nivel de permisos distinto para cada una.",
tableScope: "Ámbito de tablas",
allTables: "Todas las tablas",
specificTables: "Tablas específicas",
chooseTables: "Elegir tablas",
selectedTableCount: "{count} tablas seleccionadas",
searchTable: "Buscar tablas",
loadingTables: "Cargando tablas...",
emptyTables: "No se encontraron tablas",
loadTablesFailed: "No se pudieron cargar las tablas: {message}",
tableSelectionRequired: "Selecciona al menos una tabla.",
selectAllTables: "Seleccionar todas",
clearAllTables: "Limpiar",
retry: "Reintentar",
selectedDatabaseCount: "{count} bases de datos",
searchDatabase: "Buscar bases de datos",
loadingDatabases: "Cargando bases de datos...",
@@ -3050,6 +3063,7 @@ export default withEnglishFallback({
stepCreateUser: "Crear usuario o rol",
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}"',
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}"',
+14
View File
@@ -3028,6 +3028,19 @@ export default withEnglishFallback({
adminUserWarning: "Questo account può gestire database e altri utenti. Controlla attentamente l'SQL completo prima di applicarlo.",
databaseAccess: "Accesso ai database",
databaseAccessHint: "Seleziona i database e configura un livello di autorizzazione distinto per ciascuno.",
tableScope: "Ambito tabelle",
allTables: "Tutte le tabelle",
specificTables: "Tabelle specifiche",
chooseTables: "Scegli tabelle",
selectedTableCount: "{count} tabelle selezionate",
searchTable: "Cerca tabelle",
loadingTables: "Caricamento tabelle...",
emptyTables: "Nessuna tabella trovata",
loadTablesFailed: "Impossibile caricare le tabelle: {message}",
tableSelectionRequired: "Seleziona almeno una tabella.",
selectAllTables: "Seleziona tutte",
clearAllTables: "Cancella",
retry: "Riprova",
selectedDatabaseCount: "{count} database",
searchDatabase: "Cerca database",
loadingDatabases: "Caricamento database...",
@@ -3048,6 +3061,7 @@ export default withEnglishFallback({
stepCreateUser: "Crea utente o ruolo",
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}"',
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}"',
+14
View File
@@ -3091,6 +3091,19 @@ export default withEnglishFallback({
adminUserWarning: "このアカウントはデータベースや他のユーザーを管理できます。適用前に完全なSQLを確認してください。",
databaseAccess: "データベースアクセス",
databaseAccessHint: "データベースを選択し、それぞれに個別の権限レベルを設定します。",
tableScope: "テーブル範囲",
allTables: "すべてのテーブル",
specificTables: "指定テーブル",
chooseTables: "テーブルを選択",
selectedTableCount: "{count}テーブル選択中",
searchTable: "テーブルを検索",
loadingTables: "テーブルを読み込み中...",
emptyTables: "テーブルが見つかりません",
loadTablesFailed: "テーブルを読み込めませんでした: {message}",
tableSelectionRequired: "少なくとも1つのテーブルを選択してください。",
selectAllTables: "すべて選択",
clearAllTables: "クリア",
retry: "再試行",
selectedDatabaseCount: "{count}個のデータベース",
searchDatabase: "データベースを検索",
loadingDatabases: "データベースを読み込み中...",
@@ -3111,6 +3124,7 @@ export default withEnglishFallback({
stepCreateUser: "ユーザーまたはロールを作成",
stepGrantAdmin: "{user} に管理権限を付与",
stepGrantDatabase: "{user} にデータベース「{database}」の権限を付与",
stepGrantTable: "{user} にテーブル「{database}.{table}」の権限を付与",
stepGrantObjects: "{user} にデータベース「{database}」のスキーマとオブジェクト権限を付与",
stepGrantCurrentObjects: "{user} にデータベース「{database}」のスキーマ「{schema}」にある既存の{scope}権限を付与",
stepGrantFutureObjects: "現在のロールがデータベース「{database}」で今後作成する{scope}の権限を {user} に付与",
+14
View File
@@ -3020,6 +3020,19 @@ export default withEnglishFallback({
adminUserWarning: "이 계정은 데이터베이스와 다른 사용자를 관리할 수 있습니다. 적용하기 전에 전체 SQL을 주의 깊게 검토하세요.",
databaseAccess: "데이터베이스 접근",
databaseAccessHint: "데이터베이스를 선택하고 각각에 대해 별도의 권한 수준을 구성하세요.",
tableScope: "테이블 범위",
allTables: "모든 테이블",
specificTables: "지정 테이블",
chooseTables: "테이블 선택",
selectedTableCount: "테이블 {count}개 선택됨",
searchTable: "테이블 검색",
loadingTables: "테이블을 불러오는 중...",
emptyTables: "테이블이 없습니다",
loadTablesFailed: "테이블을 불러올 수 없습니다: {message}",
tableSelectionRequired: "테이블을 하나 이상 선택하세요.",
selectAllTables: "모두 선택",
clearAllTables: "지우기",
retry: "다시 시도",
selectedDatabaseCount: "데이터베이스 {count}개",
searchDatabase: "데이터베이스 검색",
loadingDatabases: "데이터베이스를 불러오는 중...",
@@ -3040,6 +3053,7 @@ export default withEnglishFallback({
stepCreateUser: "사용자 또는 역할 만들기",
stepGrantAdmin: "{user}에게 관리자 권한 부여",
stepGrantDatabase: '{user}에게 데이터베이스 "{database}" 접근 권한 부여',
stepGrantTable: '{user}에게 테이블 "{database}.{table}" 접근 권한 부여',
stepGrantObjects: '"{database}"에서 {user}에게 스키마 및 객체 권한 부여',
stepGrantCurrentObjects: '"{database}"에서 "{schema}" 스키마의 현재 {scope}을(를) {user}에게 부여',
stepGrantFutureObjects: '"{database}"에서 현재 역할이 생성할 향후 {scope}을(를) {user}에게 부여',
+14
View File
@@ -3030,6 +3030,19 @@ export default withEnglishFallback({
adminUserWarning: "Esta conta pode gerenciar bancos de dados e outros usuários. Revise cuidadosamente o SQL completo antes de aplicá-lo.",
databaseAccess: "Acesso a bancos de dados",
databaseAccessHint: "Selecione os bancos de dados e configure um nível de permissão separado para cada um.",
tableScope: "Escopo de tabelas",
allTables: "Todas as tabelas",
specificTables: "Tabelas específicas",
chooseTables: "Escolher tabelas",
selectedTableCount: "{count} tabelas selecionadas",
searchTable: "Pesquisar tabelas",
loadingTables: "Carregando tabelas...",
emptyTables: "Nenhuma tabela encontrada",
loadTablesFailed: "Não foi possível carregar as tabelas: {message}",
tableSelectionRequired: "Selecione pelo menos uma tabela.",
selectAllTables: "Selecionar todas",
clearAllTables: "Limpar",
retry: "Tentar novamente",
selectedDatabaseCount: "{count} bancos de dados",
searchDatabase: "Pesquisar bancos de dados",
loadingDatabases: "Carregando bancos de dados...",
@@ -3050,6 +3063,7 @@ export default withEnglishFallback({
stepCreateUser: "Criar usuário ou função",
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}"',
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}"',
+14
View File
@@ -3180,6 +3180,19 @@ export default withEnglishFallback({
adminUserWarning: "该账号可以管理数据库和其他用户。执行前请仔细检查完整 SQL。",
databaseAccess: "可访问数据库",
databaseAccessHint: "选择数据库,并为每个数据库分别配置权限级别。",
tableScope: "表范围",
allTables: "全部表",
specificTables: "指定表",
chooseTables: "选择表",
selectedTableCount: "已选 {count} 张表",
searchTable: "搜索表",
loadingTables: "正在加载表...",
emptyTables: "未找到表",
loadTablesFailed: "加载表失败:{message}",
tableSelectionRequired: "请至少选择一张表。",
selectAllTables: "全选",
clearAllTables: "清空",
retry: "重试",
selectedDatabaseCount: "已选 {count} 个数据库",
searchDatabase: "搜索数据库",
loadingDatabases: "正在加载数据库...",
@@ -3200,6 +3213,7 @@ export default withEnglishFallback({
stepCreateUser: "创建用户或角色",
stepGrantAdmin: "为 {user} 授予管理权限",
stepGrantDatabase: "为 {user} 授予数据库「{database}」权限",
stepGrantTable: "为 {user} 授予表「{database}.{table}」权限",
stepGrantObjects: "为 {user} 授予数据库「{database}」的 Schema 与对象权限",
stepGrantCurrentObjects: "为 {user} 授予数据库「{database}」中 Schema「{schema}」的现有{scope}权限",
stepGrantFutureObjects: "为 {user} 授予当前角色在数据库「{database}」中未来创建的{scope}权限",
+14
View File
@@ -6086,6 +6086,19 @@ export default withEnglishFallback({
adminUserWarning: "此帳號可以管理資料庫和其他使用者。執行前請仔細檢查完整 SQL。",
databaseAccess: "可存取資料庫",
databaseAccessHint: "選擇資料庫,並為每個資料庫分別設定權限層級。",
tableScope: "資料表範圍",
allTables: "全部資料表",
specificTables: "指定資料表",
chooseTables: "選擇資料表",
selectedTableCount: "已選 {count} 張資料表",
searchTable: "搜尋資料表",
loadingTables: "正在載入資料表...",
emptyTables: "找不到資料表",
loadTablesFailed: "載入資料表失敗:{message}",
tableSelectionRequired: "請至少選擇一張資料表。",
selectAllTables: "全選",
clearAllTables: "清除",
retry: "重試",
selectedDatabaseCount: "已選 {count} 個資料庫",
searchDatabase: "搜尋資料庫",
loadingDatabases: "正在載入資料庫...",
@@ -6106,6 +6119,7 @@ export default withEnglishFallback({
stepCreateUser: "建立使用者或角色",
stepGrantAdmin: "為 {user} 授予管理權限",
stepGrantDatabase: "為 {user} 授予資料庫「{database}」權限",
stepGrantTable: "為 {user} 授予資料表「{database}.{table}」權限",
stepGrantObjects: "為 {user} 授予資料庫「{database}」的 Schema 與物件權限",
stepGrantCurrentObjects: "為 {user} 授予資料庫「{database}」中 Schema「{schema}」的既有{scope}權限",
stepGrantFutureObjects: "為 {user} 授予目前角色在資料庫「{database}」中未來建立的{scope}權限",
@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
executeMulti: vi.fn(),
productionGuard: vi.fn(),
toast: vi.fn(),
listDatabases: vi.fn(),
listTables: vi.fn(),
listSchemas: vi.fn(),
}));
function passthrough(tag: string): Component {
@@ -44,6 +47,21 @@ function modelDialog(): Component {
});
}
function passwordInput(): Component {
return defineComponent({
inheritAttrs: false,
setup(_, { attrs }) {
return () =>
h("input", {
...attrs,
"data-password-input": "true",
value: attrs.modelValue as string,
onInput: (event: Event) => (attrs["onUpdate:modelValue"] as ((value: string) => void) | undefined)?.((event.target as HTMLInputElement).value),
});
},
});
}
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string, values?: Record<string, unknown>) => (values?.message ? `${key}: ${values.message}` : key),
@@ -54,6 +72,7 @@ vi.mock("@lucide/vue", () => {
return {
AlertTriangle: Icon,
Check: Icon,
ChevronDown: Icon,
Globe2: Icon,
KeyRound: Icon,
Lock: Icon,
@@ -62,6 +81,7 @@ vi.mock("@lucide/vue", () => {
RefreshCcw: Icon,
Search: Icon,
ShieldCheck: Icon,
Table2: Icon,
Trash2: Icon,
Unlock: Icon,
UserRound: Icon,
@@ -77,7 +97,12 @@ vi.mock("@/components/ui/dialog", () => ({
DialogTitle: passthrough("div"),
}));
vi.mock("@/components/ui/input", () => ({ Input: modelInput() }));
vi.mock("@/components/ui/PasswordInput.vue", () => ({ default: passthrough("input") }));
vi.mock("@/components/ui/PasswordInput.vue", () => ({ default: passwordInput() }));
vi.mock("@/components/ui/popover", () => ({
Popover: passthrough("div"),
PopoverContent: passthrough("div"),
PopoverTrigger: passthrough("div"),
}));
vi.mock("@/components/ui/select", () => ({
Select: passthrough("div"),
SelectContent: passthrough("div"),
@@ -93,8 +118,9 @@ vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({
vi.mock("@/lib/backend/api", () => ({
executeQuery: mocks.executeQuery,
executeMulti: mocks.executeMulti,
listDatabases: vi.fn(),
listSchemas: vi.fn(),
listDatabases: mocks.listDatabases,
listTables: mocks.listTables,
listSchemas: mocks.listSchemas,
}));
vi.mock("@/lib/database/productionExecutionGuard", () => ({
executeWithProductionSqlGuard: (options: { execute: () => Promise<unknown> }) => {
@@ -193,6 +219,47 @@ function findButton(text: string): HTMLButtonElement | undefined {
return Array.from(root?.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);
mocks.executeQuery.mockResolvedValueOnce({ columns: ["user", "host", "plugin"], rows: [["root", "%", "caching_sha2_password"]] }).mockResolvedValueOnce({ columns: ["Grants"], rows: [["GRANT ALL PRIVILEGES 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));
findButton("userAdmin.newUser")?.click();
await vi.waitFor(() => expect(mocks.listDatabases).toHaveBeenCalledWith("native-mysql"));
await nextTick();
findButton("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();
await vi.waitFor(() => expect(mocks.listTables).toHaveBeenCalledWith("native-mysql", "scope_test", ""));
await nextTick();
expect(findButton("userAdmin.previewSql")?.disabled).toBe(true);
findButton("allowed_table")?.click();
await nextTick();
expect(findButton("userAdmin.previewSql")?.disabled).toBe(false);
findButton("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`.*");
expect(root.textContent).not.toContain("`blocked_table`");
});
});
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'"]] });
@@ -121,6 +121,37 @@ describe("database authorization plans", () => {
expect(grant).toMatchObject({ subject: "app@2001:db8::1", targetDatabase: "db:prod" });
});
it("grants only selected MySQL tables and filters database-only privileges", () => {
const plan = buildCreateUserAuthorizationPlan({
provider: mysqlUserAdminProvider,
principal: { user: "table_reader", host: "%", password: "secret" },
accountType: "standard",
databases: [{ database: "app_db", preset: "readWrite", tables: ["orders", "audit`log", "orders"] }],
});
const sql = authorizationPlanSql(plan);
const grants = plan.steps.filter((step) => step.operation === "grantDatabase");
expect(grants).toHaveLength(2);
expect(grants.map((step) => step.targetTable)).toEqual(["orders", "audit`log"]);
expect(sql).toContain("GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, INDEX, REFERENCES, SHOW VIEW, CREATE VIEW, TRIGGER ON `app_db`.`orders` TO 'table_reader'@'%';");
expect(sql).toContain("ON `app_db`.`audit``log`");
expect(sql).not.toContain("ON `app_db`.*");
expect(sql).not.toContain("EVENT");
expect(sql).not.toContain("CREATE ROUTINE");
expect(sql).not.toContain("LOCK TABLES");
});
it("keeps database-wide MySQL grants as the default", () => {
const plan = buildCreateUserAuthorizationPlan({
provider: mysqlUserAdminProvider,
principal: { user: "db_reader", host: "%", password: "secret" },
accountType: "standard",
databases: [{ database: "app_db", preset: "readOnly" }],
});
expect(authorizationPlanSql(plan)).toContain("GRANT SELECT, SHOW VIEW ON `app_db`.* TO 'db_reader'@'%';");
});
it("reports PostgreSQL object grants independently", async () => {
const plan = buildCreateDatabaseAuthorizationPlan({
provider: postgresUserAdminProvider,
@@ -3,6 +3,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 AuthorizationObjectScope = "schemas" | "tables" | "sequences" | "functions";
@@ -11,6 +12,7 @@ export interface DatabaseAuthorizationSelection {
preset: AuthorizationPreset;
privileges?: string[];
schemas?: string[];
tables?: string[];
}
export interface AuthorizationPlanStep {
@@ -22,6 +24,7 @@ export interface AuthorizationPlanStep {
operation: AuthorizationStepOperation;
subject?: string;
targetDatabase?: string;
targetTable?: string;
objectScope?: AuthorizationObjectScope;
schema?: string;
owner?: string;
@@ -60,6 +63,8 @@ const MYSQL_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]> =
readWrite: ["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "EXECUTE", "SHOW VIEW", "CREATE VIEW", "CREATE ROUTINE", "ALTER ROUTINE", "TRIGGER", "EVENT", "CREATE TEMPORARY TABLES", "LOCK TABLES"],
};
const MYSQL_TABLE_PRIVILEGES = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "CREATE", "DROP", "ALTER", "INDEX", "REFERENCES", "SHOW VIEW", "CREATE VIEW", "TRIGGER"]);
const POSTGRES_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]> = {
readOnly: ["SELECT"],
dml: ["SELECT", "INSERT", "UPDATE", "DELETE"],
@@ -67,15 +72,18 @@ const POSTGRES_PRESETS: Record<Exclude<AuthorizationPreset, "custom">, string[]>
readWrite: ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "EXECUTE", "CREATE", "TEMPORARY"],
};
export function authorizationPresetPrivileges(provider: DatabaseUserAdminProvider, preset: AuthorizationPreset, custom: string[] = []): string[] {
if (preset === "custom") return uniquePrivileges(custom);
return [...(provider.dialect === "mysql" ? MYSQL_PRESETS[preset] : POSTGRES_PRESETS[preset])];
export function authorizationPresetPrivileges(provider: DatabaseUserAdminProvider, preset: AuthorizationPreset, custom: string[] = [], targetScope: AuthorizationTargetScope = "database"): string[] {
const privileges = preset === "custom" ? uniquePrivileges(custom) : [...(provider.dialect === "mysql" ? MYSQL_PRESETS[preset] : POSTGRES_PRESETS[preset])];
return provider.dialect === "mysql" && targetScope === "table" ? privileges.filter((privilege) => MYSQL_TABLE_PRIVILEGES.has(privilege)) : privileges;
}
export function authorizationPrivileges(provider: DatabaseUserAdminProvider): string[] {
export function authorizationPrivileges(provider: DatabaseUserAdminProvider, targetScope: AuthorizationTargetScope = "database"): string[] {
const privilegesForScope = provider.privilegesForScope;
if (!privilegesForScope) return [];
if (provider.dialect === "mysql") return Array.from(privilegesForScope("mysql"));
if (provider.dialect === "mysql") {
const privileges = Array.from(privilegesForScope("mysql"));
return targetScope === "table" ? privileges.filter((privilege) => MYSQL_TABLE_PRIVILEGES.has(privilege)) : privileges;
}
return uniquePrivileges([...privilegesForScope("database"), ...privilegesForScope("schema"), ...privilegesForScope("table"), "EXECUTE", "USAGE", "UPDATE"]);
}
@@ -116,20 +124,26 @@ export function buildCreateUserAuthorizationPlan(input: CreateUserAuthorizationP
for (const selection of input.databases) {
const database = selection.database.trim();
if (!database) continue;
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges);
if (input.provider.dialect === "mysql") {
steps.push({
id: `grant-${steps.length}`,
label: `grant ${input.provider.label(identity)} access to ${database}`,
database: "",
sql: input.provider.grantPrivilegesSql({ user: identity, privileges, database, table: "*", scope: "mysql" }),
dependsOn: [createStepId],
operation: "grantDatabase",
subject: input.provider.label(identity),
targetDatabase: database,
});
const targetScope = input.provider.supportsTableGrantsOnCreate && selection.tables !== undefined ? "table" : "database";
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges, targetScope);
const tables = targetScope === "database" ? ["*"] : uniqueNames(selection.tables ?? []);
for (const table of tables) {
steps.push({
id: `grant-${steps.length}`,
label: `grant ${input.provider.label(identity)} access to ${table === "*" ? database : `${database}.${table}`}`,
database: "",
sql: input.provider.grantPrivilegesSql({ user: identity, privileges, database, table, scope: "mysql" }),
dependsOn: [createStepId],
operation: "grantDatabase",
subject: input.provider.label(identity),
targetDatabase: database,
targetTable: table === "*" ? undefined : table,
});
}
continue;
}
const privileges = authorizationPresetPrivileges(input.provider, selection.preset, selection.privileges);
steps.push(...postgresDatabaseAuthorizationSteps(identity, database, privileges, selection.schemas ?? ["public"], createStepId, steps.length));
}
return { steps };
@@ -418,6 +432,10 @@ function uniquePrivileges(privileges: string[]): string[] {
return Array.from(new Set(privileges.map((privilege) => privilege.trim().toUpperCase()).filter(Boolean)));
}
function uniqueNames(names: string[]): string[] {
return Array.from(new Set(names.map((name) => name.trim()).filter(Boolean)));
}
function queryResultMessage(result: QueryResult): string {
return String(result.rows[0]?.[0] ?? "Execution failed");
}
@@ -41,6 +41,7 @@ export interface PrivilegeSelection {
export interface DatabaseUserAdminProvider {
dialect: UserAdminDialect;
defaultScope: PrivilegeScope;
supportsTableGrantsOnCreate?: boolean;
listUsersSql(): string;
fallbackListUsersSql?: () => string;
parseUsers(result: QueryResult): DatabaseUserIdentity[];
@@ -689,6 +690,7 @@ function postgresRoleBypassRlsSql(alias: string): string {
export const mysqlUserAdminProvider: DatabaseUserAdminProvider = {
dialect: "mysql",
defaultScope: "mysql",
supportsTableGrantsOnCreate: true,
listUsersSql: mysqlListUsersSql,
fallbackListUsersSql: mysqlListUsersFallbackSql,
parseUsers: usersFromMySqlUserResult,