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 (