feat(mysql): support table-scoped grants for new users
This commit is contained in:
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]"
|
||||
|
||||
Reference in new issue
Block a user