From 6dd5b161ea0f84aed7f235b9459bed5804c33b80 Mon Sep 17 00:00:00 2001 From: Mason Hu Date: Thu, 30 Jan 2025 17:49:18 +0200 Subject: [PATCH] experiment: initial approach for restricted users permission checks Signed-off-by: Mason Hu --- src/api/auth-identities.tsx | 9 ++++++ src/api/instances.tsx | 18 ++++++++--- src/context/auth.tsx | 23 ++++++++++++++ src/context/useInstances.tsx | 30 +++++++++++++++++++ src/context/useSupportedFeatures.tsx | 3 ++ src/pages/instances/InstanceDetail.tsx | 9 ++---- src/pages/instances/InstanceDetailPanel.tsx | 12 ++------ src/pages/instances/InstanceList.tsx | 11 ++----- .../instances/actions/FreezeInstanceBtn.tsx | 8 +++-- .../instances/actions/RestartInstanceBtn.tsx | 8 +++-- .../instances/actions/StartInstanceBtn.tsx | 10 +++++-- .../instances/actions/StopInstanceBtn.tsx | 8 +++-- .../forms/CreateInstanceFromSnapshotForm.tsx | 8 ++--- .../instances/forms/DuplicateInstanceForm.tsx | 8 ++--- src/types/instance.d.ts | 1 + src/types/permissions.d.ts | 1 + src/util/entitlements/api.tsx | 14 +++++++++ src/util/entitlements/helpers.tsx | 13 ++++++++ src/util/entitlements/instances.tsx | 18 +++++++++++ src/util/queryKeys.tsx | 1 + 20 files changed, 166 insertions(+), 47 deletions(-) create mode 100644 src/context/useInstances.tsx create mode 100644 src/util/entitlements/api.tsx create mode 100644 src/util/entitlements/helpers.tsx create mode 100644 src/util/entitlements/instances.tsx diff --git a/src/api/auth-identities.tsx b/src/api/auth-identities.tsx index 5a1248ec90..3a0f4455a3 100644 --- a/src/api/auth-identities.tsx +++ b/src/api/auth-identities.tsx @@ -11,6 +11,15 @@ export const fetchIdentities = (): Promise => { }); }; +export const fetchCurrentIdentity = (): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identities/current?recursion=1`) + .then(handleResponse) + .then((data: LxdApiResponse) => resolve(data.metadata)) + .catch(reject); + }); +}; + export const fetchIdentity = ( id: string, authMethod: string, diff --git a/src/api/instances.tsx b/src/api/instances.tsx index 439ecfc613..45c724d118 100644 --- a/src/api/instances.tsx +++ b/src/api/instances.tsx @@ -13,23 +13,33 @@ import type { LxdOperationResponse } from "types/operation"; import { EventQueue } from "context/eventQueue"; import axios, { AxiosResponse } from "axios"; import type { UploadState } from "types/storage"; +import { withEntitlementsQuery } from "util/entitlements/api"; + +export const instanceEntitlements = ["can_update_state"]; export const fetchInstance = ( name: string, project: string, - recursion = 2, + isFineGrained: boolean | null, ): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, instanceEntitlements)}`; return new Promise((resolve, reject) => { - fetch(`/1.0/instances/${name}?project=${project}&recursion=${recursion}`) + fetch( + `/1.0/instances/${name}?project=${project}&recursion=2${entitlements}`, + ) .then(handleEtagResponse) .then((data) => resolve(data as LxdInstance)) .catch(reject); }); }; -export const fetchInstances = (project: string): Promise => { +export const fetchInstances = ( + project: string, + isFineGrained: boolean | null, +): Promise => { + const entitlements = `&${withEntitlementsQuery(isFineGrained, instanceEntitlements)}`; return new Promise((resolve, reject) => { - fetch(`/1.0/instances?project=${project}&recursion=2`) + fetch(`/1.0/instances?project=${project}&recursion=2${entitlements}`) .then(handleResponse) .then((data: LxdApiResponse) => resolve(data.metadata)) .catch(reject); diff --git a/src/context/auth.tsx b/src/context/auth.tsx index 675e1c0fa9..efd75577da 100644 --- a/src/context/auth.tsx +++ b/src/context/auth.tsx @@ -4,6 +4,8 @@ import { queryKeys } from "util/queryKeys"; import { fetchCertificates } from "api/certificates"; import { useSettings } from "context/useSettings"; import { fetchProjects } from "api/projects"; +import { fetchCurrentIdentity } from "api/auth-identities"; +import { useSupportedFeatures } from "./useSupportedFeatures"; interface ContextProps { isAuthenticated: boolean; @@ -12,6 +14,7 @@ interface ContextProps { isRestricted: boolean; defaultProject: string; hasNoProjects: boolean; + isFineGrained: boolean | null; } const initialState: ContextProps = { @@ -21,6 +24,7 @@ const initialState: ContextProps = { isRestricted: false, defaultProject: "default", hasNoProjects: false, + isFineGrained: null, }; export const AuthContext = createContext(initialState); @@ -32,6 +36,9 @@ interface ProviderProps { export const AuthProvider: FC = ({ children }) => { const { data: settings, isLoading } = useSettings(); + const { hasEntitiesWithEntitlements, isSettingsLoading } = + useSupportedFeatures(); + const { data: projects = [], isLoading: isProjectsLoading } = useQuery({ queryKey: [queryKeys.projects], queryFn: fetchProjects, @@ -51,11 +58,26 @@ export const AuthProvider: FC = ({ children }) => { enabled: isTls, }); + const { data: currentIdentity } = useQuery({ + queryKey: [queryKeys.currentIdentity], + queryFn: fetchCurrentIdentity, + retry: false, // avoid retry for older versions of lxd less than 5.21 due to missing endpoint + }); + const fingerprint = isTls ? settings.auth_user_name : undefined; const certificate = certificates.find( (certificate) => certificate.fingerprint === fingerprint, ); const isRestricted = certificate?.restricted ?? defaultProject !== "default"; + const isFineGrained = () => { + if (isSettingsLoading) { + return null; + } + if (hasEntitiesWithEntitlements) { + return currentIdentity?.fine_grained ?? null; + } + return false; + }; return ( = ({ children }) => { isRestricted, defaultProject, hasNoProjects: projects.length === 0 && !isProjectsLoading, + isFineGrained: isFineGrained(), }} > {children} diff --git a/src/context/useInstances.tsx b/src/context/useInstances.tsx new file mode 100644 index 0000000000..f053f36ac9 --- /dev/null +++ b/src/context/useInstances.tsx @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; +import { UseQueryResult } from "@tanstack/react-query"; +import { fetchInstance, fetchInstances } from "api/instances"; +import { useAuth } from "./auth"; +import type { LxdInstance } from "types/instance"; + +export const useInstances = ( + project: string, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + return useQuery({ + queryKey: [queryKeys.instances, project], + queryFn: () => fetchInstances(project, isFineGrained), + enabled: !!project && isFineGrained !== null, + }); +}; + +export const useInstance = ( + name: string, + project: string, + enabled?: boolean, +): UseQueryResult => { + const { isFineGrained } = useAuth(); + return useQuery({ + queryKey: [queryKeys.instances, name, project], + queryFn: () => fetchInstance(name, project, isFineGrained), + enabled: enabled && isFineGrained !== null, + }); +}; diff --git a/src/context/useSupportedFeatures.tsx b/src/context/useSupportedFeatures.tsx index 0245f74a2f..321a0ace9b 100644 --- a/src/context/useSupportedFeatures.tsx +++ b/src/context/useSupportedFeatures.tsx @@ -36,5 +36,8 @@ export const useSupportedFeatures = () => { hasClusterInternalCustomVolumeCopy: apiExtensions.has( "cluster_internal_custom_volume_copy", ), + hasEntitiesWithEntitlements: apiExtensions.has( + "entities_with_entitlements", + ), }; }; diff --git a/src/pages/instances/InstanceDetail.tsx b/src/pages/instances/InstanceDetail.tsx index 6f68d1b77b..b03acdc1f9 100644 --- a/src/pages/instances/InstanceDetail.tsx +++ b/src/pages/instances/InstanceDetail.tsx @@ -4,9 +4,6 @@ import InstanceOverview from "./InstanceOverview"; import InstanceTerminal from "./InstanceTerminal"; import { useParams } from "react-router-dom"; import InstanceSnapshots from "./InstanceSnapshots"; -import { useQuery } from "@tanstack/react-query"; -import { fetchInstance } from "api/instances"; -import { queryKeys } from "util/queryKeys"; import Loader from "components/Loader"; import InstanceConsole from "pages/instances/InstanceConsole"; import InstanceLogs from "pages/instances/InstanceLogs"; @@ -16,6 +13,7 @@ import CustomLayout from "components/CustomLayout"; import TabLinks from "components/TabLinks"; import { useSettings } from "context/useSettings"; import { TabLink } from "@canonical/react-components/dist/components/Tabs/Tabs"; +import { useInstance } from "context/useInstances"; const tabs: string[] = [ "Overview", @@ -47,10 +45,7 @@ const InstanceDetail: FC = () => { error, refetch: refreshInstance, isLoading, - } = useQuery({ - queryKey: [queryKeys.instances, name, project], - queryFn: () => fetchInstance(name, project), - }); + } = useInstance(name, project); const renderTabs: (string | TabLink)[] = [...tabs]; diff --git a/src/pages/instances/InstanceDetailPanel.tsx b/src/pages/instances/InstanceDetailPanel.tsx index 9a91f6afc5..6d70da8ce0 100644 --- a/src/pages/instances/InstanceDetailPanel.tsx +++ b/src/pages/instances/InstanceDetailPanel.tsx @@ -2,28 +2,22 @@ import { FC } from "react"; import OpenTerminalBtn from "./actions/OpenTerminalBtn"; import OpenConsoleBtn from "./actions/OpenConsoleBtn"; import { Button, Icon, List, useNotify } from "@canonical/react-components"; -import { useQuery } from "@tanstack/react-query"; -import { fetchInstance } from "api/instances"; -import { queryKeys } from "util/queryKeys"; import usePanelParams from "util/usePanelParams"; import InstanceStateActions from "pages/instances/actions/InstanceStateActions"; import SidePanel from "components/SidePanel"; import InstanceDetailPanelContent from "./InstanceDetailPanelContent"; +import { useInstance } from "context/useInstances"; const InstanceDetailPanel: FC = () => { const notify = useNotify(); const panelParams = usePanelParams(); + const enable = panelParams.instance !== null; const { data: instance, error, isLoading, - } = useQuery({ - queryKey: [queryKeys.instances, panelParams.instance, panelParams.project], - queryFn: () => - fetchInstance(panelParams.instance ?? "", panelParams.project), - enabled: panelParams.instance !== null, - }); + } = useInstance(panelParams.instance ?? "", panelParams.project, enable); if (error) { notify.failure("Loading instance failed", error); diff --git a/src/pages/instances/InstanceList.tsx b/src/pages/instances/InstanceList.tsx index 50879537ac..9f50bb9693 100644 --- a/src/pages/instances/InstanceList.tsx +++ b/src/pages/instances/InstanceList.tsx @@ -9,7 +9,6 @@ import { TablePagination, useNotify, } from "@canonical/react-components"; -import { fetchInstances } from "api/instances"; import { useQuery } from "@tanstack/react-query"; import { queryKeys } from "util/queryKeys"; import usePanelParams, { panels } from "util/usePanelParams"; @@ -65,6 +64,7 @@ import { useSettings } from "context/useSettings"; import { isClusteredServer } from "util/settings"; import InstanceUsageMemory from "pages/instances/InstanceUsageMemory"; import InstanceUsageDisk from "pages/instances/InstanceDisk"; +import { useInstances } from "context/useInstances"; const loadHidden = () => { const saved = localStorage.getItem("instanceListHiddenColumns"); @@ -111,14 +111,7 @@ const InstanceList: FC = () => { return <>Missing project; } - const { - data: instances = [], - error, - isLoading, - } = useQuery({ - queryKey: [queryKeys.instances, project], - queryFn: () => fetchInstances(project), - }); + const { data: instances = [], error, isLoading } = useInstances(project); if (error) { notify.failure("Loading instances failed", error); diff --git a/src/pages/instances/actions/FreezeInstanceBtn.tsx b/src/pages/instances/actions/FreezeInstanceBtn.tsx index 2a3cdd8a74..3b900cb64d 100644 --- a/src/pages/instances/actions/FreezeInstanceBtn.tsx +++ b/src/pages/instances/actions/FreezeInstanceBtn.tsx @@ -9,6 +9,7 @@ import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; import ItemName from "components/ItemName"; import InstanceLinkChip from "../InstanceLinkChip"; +import { useInstanceEntitlements } from "util/entitlements/instances"; interface Props { instance: LxdInstance; @@ -19,6 +20,7 @@ const FreezeInstanceBtn: FC = ({ instance }) => { const instanceLoading = useInstanceLoading(); const toastNotify = useToastNotification(); const queryClient = useQueryClient(); + const { canUpdateInstanceState } = useInstanceEntitlements(instance); const clearCache = () => { void queryClient.invalidateQueries({ @@ -81,10 +83,12 @@ const FreezeInstanceBtn: FC = ({ instance }) => {

), onConfirm: handleFreeze, - confirmButtonLabel: "Freeze", + confirmButtonLabel: canUpdateInstanceState() + ? "Freeze" + : "You do not have permission to freeze this instance", }} className="has-icon is-dense" - disabled={isDisabled} + disabled={isDisabled || !canUpdateInstanceState()} shiftClickEnabled showShiftClickHint > diff --git a/src/pages/instances/actions/RestartInstanceBtn.tsx b/src/pages/instances/actions/RestartInstanceBtn.tsx index 949bf3de2d..9d6c4ec9d2 100644 --- a/src/pages/instances/actions/RestartInstanceBtn.tsx +++ b/src/pages/instances/actions/RestartInstanceBtn.tsx @@ -10,6 +10,7 @@ import { useEventQueue } from "context/eventQueue"; import { useToastNotification } from "context/toastNotificationProvider"; import ItemName from "components/ItemName"; import InstanceLinkChip from "../InstanceLinkChip"; +import { useInstanceEntitlements } from "util/entitlements/instances"; interface Props { instance: LxdInstance; @@ -24,6 +25,7 @@ const RestartInstanceBtn: FC = ({ instance }) => { const isLoading = instanceLoading.getType(instance) === "Restarting" || instance.status === "Restarting"; + const { canUpdateInstanceState } = useInstanceEntitlements(instance); const instanceLink = ; @@ -74,7 +76,9 @@ const RestartInstanceBtn: FC = ({ instance }) => { ), onConfirm: handleRestart, close: () => setForce(false), - confirmButtonLabel: "Restart", + confirmButtonLabel: canUpdateInstanceState() + ? "Restart" + : "You do not have permission to restart this instance", confirmExtra: ( = ({ instance }) => { /> ), }} - disabled={isDisabled} + disabled={isDisabled || !canUpdateInstanceState()} shiftClickEnabled showShiftClickHint > diff --git a/src/pages/instances/actions/StartInstanceBtn.tsx b/src/pages/instances/actions/StartInstanceBtn.tsx index 04855398b4..6d76f7eabc 100644 --- a/src/pages/instances/actions/StartInstanceBtn.tsx +++ b/src/pages/instances/actions/StartInstanceBtn.tsx @@ -3,6 +3,7 @@ import type { LxdInstance } from "types/instance"; import { Button, Icon } from "@canonical/react-components"; import classnames from "classnames"; import { useInstanceStart } from "util/instanceStart"; +import { useInstanceEntitlements } from "util/entitlements/instances"; interface Props { instance: LxdInstance; @@ -10,17 +11,22 @@ interface Props { const StartInstanceBtn: FC = ({ instance }) => { const { handleStart, isLoading, isDisabled } = useInstanceStart(instance); + const { canUpdateInstanceState } = useInstanceEntitlements(instance); return (