Skip to content

Commit ed96d40

Browse files
authored
feat: initial approach for restricted users permission checks [WD-18836] (#1082)
## Done - Check if user is restricted by using `/1.0/identities/current` endpoint - Wrap `fetchInstances` and `fetchInstance` inside custom hooks to apply entitlement query params if the user is restricted - Setup custom hook for checking instance entitlements - Applied entitlement checks for instance state action buttons on the instance list page.
2 parents 83ba657 + 6dd5b16 commit ed96d40

20 files changed

+166
-47
lines changed

src/api/auth-identities.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ export const fetchIdentities = (): Promise<LxdIdentity[]> => {
1111
});
1212
};
1313

14+
export const fetchCurrentIdentity = (): Promise<LxdIdentity> => {
15+
return new Promise((resolve, reject) => {
16+
fetch(`/1.0/auth/identities/current?recursion=1`)
17+
.then(handleResponse)
18+
.then((data: LxdApiResponse<LxdIdentity>) => resolve(data.metadata))
19+
.catch(reject);
20+
});
21+
};
22+
1423
export const fetchIdentity = (
1524
id: string,
1625
authMethod: string,

src/api/instances.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,33 @@ import type { LxdOperationResponse } from "types/operation";
1313
import { EventQueue } from "context/eventQueue";
1414
import axios, { AxiosResponse } from "axios";
1515
import type { UploadState } from "types/storage";
16+
import { withEntitlementsQuery } from "util/entitlements/api";
17+
18+
export const instanceEntitlements = ["can_update_state"];
1619

1720
export const fetchInstance = (
1821
name: string,
1922
project: string,
20-
recursion = 2,
23+
isFineGrained: boolean | null,
2124
): Promise<LxdInstance> => {
25+
const entitlements = `&${withEntitlementsQuery(isFineGrained, instanceEntitlements)}`;
2226
return new Promise((resolve, reject) => {
23-
fetch(`/1.0/instances/${name}?project=${project}&recursion=${recursion}`)
27+
fetch(
28+
`/1.0/instances/${name}?project=${project}&recursion=2${entitlements}`,
29+
)
2430
.then(handleEtagResponse)
2531
.then((data) => resolve(data as LxdInstance))
2632
.catch(reject);
2733
});
2834
};
2935

30-
export const fetchInstances = (project: string): Promise<LxdInstance[]> => {
36+
export const fetchInstances = (
37+
project: string,
38+
isFineGrained: boolean | null,
39+
): Promise<LxdInstance[]> => {
40+
const entitlements = `&${withEntitlementsQuery(isFineGrained, instanceEntitlements)}`;
3141
return new Promise((resolve, reject) => {
32-
fetch(`/1.0/instances?project=${project}&recursion=2`)
42+
fetch(`/1.0/instances?project=${project}&recursion=2${entitlements}`)
3343
.then(handleResponse)
3444
.then((data: LxdApiResponse<LxdInstance[]>) => resolve(data.metadata))
3545
.catch(reject);

src/context/auth.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { queryKeys } from "util/queryKeys";
44
import { fetchCertificates } from "api/certificates";
55
import { useSettings } from "context/useSettings";
66
import { fetchProjects } from "api/projects";
7+
import { fetchCurrentIdentity } from "api/auth-identities";
8+
import { useSupportedFeatures } from "./useSupportedFeatures";
79

810
interface ContextProps {
911
isAuthenticated: boolean;
@@ -12,6 +14,7 @@ interface ContextProps {
1214
isRestricted: boolean;
1315
defaultProject: string;
1416
hasNoProjects: boolean;
17+
isFineGrained: boolean | null;
1518
}
1619

1720
const initialState: ContextProps = {
@@ -21,6 +24,7 @@ const initialState: ContextProps = {
2124
isRestricted: false,
2225
defaultProject: "default",
2326
hasNoProjects: false,
27+
isFineGrained: null,
2428
};
2529

2630
export const AuthContext = createContext<ContextProps>(initialState);
@@ -32,6 +36,9 @@ interface ProviderProps {
3236
export const AuthProvider: FC<ProviderProps> = ({ children }) => {
3337
const { data: settings, isLoading } = useSettings();
3438

39+
const { hasEntitiesWithEntitlements, isSettingsLoading } =
40+
useSupportedFeatures();
41+
3542
const { data: projects = [], isLoading: isProjectsLoading } = useQuery({
3643
queryKey: [queryKeys.projects],
3744
queryFn: fetchProjects,
@@ -51,11 +58,26 @@ export const AuthProvider: FC<ProviderProps> = ({ children }) => {
5158
enabled: isTls,
5259
});
5360

61+
const { data: currentIdentity } = useQuery({
62+
queryKey: [queryKeys.currentIdentity],
63+
queryFn: fetchCurrentIdentity,
64+
retry: false, // avoid retry for older versions of lxd less than 5.21 due to missing endpoint
65+
});
66+
5467
const fingerprint = isTls ? settings.auth_user_name : undefined;
5568
const certificate = certificates.find(
5669
(certificate) => certificate.fingerprint === fingerprint,
5770
);
5871
const isRestricted = certificate?.restricted ?? defaultProject !== "default";
72+
const isFineGrained = () => {
73+
if (isSettingsLoading) {
74+
return null;
75+
}
76+
if (hasEntitiesWithEntitlements) {
77+
return currentIdentity?.fine_grained ?? null;
78+
}
79+
return false;
80+
};
5981

6082
return (
6183
<AuthContext.Provider
@@ -66,6 +88,7 @@ export const AuthProvider: FC<ProviderProps> = ({ children }) => {
6688
isRestricted,
6789
defaultProject,
6890
hasNoProjects: projects.length === 0 && !isProjectsLoading,
91+
isFineGrained: isFineGrained(),
6992
}}
7093
>
7194
{children}

src/context/useInstances.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { queryKeys } from "util/queryKeys";
3+
import { UseQueryResult } from "@tanstack/react-query";
4+
import { fetchInstance, fetchInstances } from "api/instances";
5+
import { useAuth } from "./auth";
6+
import type { LxdInstance } from "types/instance";
7+
8+
export const useInstances = (
9+
project: string,
10+
): UseQueryResult<LxdInstance[]> => {
11+
const { isFineGrained } = useAuth();
12+
return useQuery({
13+
queryKey: [queryKeys.instances, project],
14+
queryFn: () => fetchInstances(project, isFineGrained),
15+
enabled: !!project && isFineGrained !== null,
16+
});
17+
};
18+
19+
export const useInstance = (
20+
name: string,
21+
project: string,
22+
enabled?: boolean,
23+
): UseQueryResult<LxdInstance> => {
24+
const { isFineGrained } = useAuth();
25+
return useQuery({
26+
queryKey: [queryKeys.instances, name, project],
27+
queryFn: () => fetchInstance(name, project, isFineGrained),
28+
enabled: enabled && isFineGrained !== null,
29+
});
30+
};

src/context/useSupportedFeatures.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,8 @@ export const useSupportedFeatures = () => {
3636
hasClusterInternalCustomVolumeCopy: apiExtensions.has(
3737
"cluster_internal_custom_volume_copy",
3838
),
39+
hasEntitiesWithEntitlements: apiExtensions.has(
40+
"entities_with_entitlements",
41+
),
3942
};
4043
};

src/pages/instances/InstanceDetail.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ import InstanceOverview from "./InstanceOverview";
44
import InstanceTerminal from "./InstanceTerminal";
55
import { useParams } from "react-router-dom";
66
import InstanceSnapshots from "./InstanceSnapshots";
7-
import { useQuery } from "@tanstack/react-query";
8-
import { fetchInstance } from "api/instances";
9-
import { queryKeys } from "util/queryKeys";
107
import Loader from "components/Loader";
118
import InstanceConsole from "pages/instances/InstanceConsole";
129
import InstanceLogs from "pages/instances/InstanceLogs";
@@ -16,6 +13,7 @@ import CustomLayout from "components/CustomLayout";
1613
import TabLinks from "components/TabLinks";
1714
import { useSettings } from "context/useSettings";
1815
import { TabLink } from "@canonical/react-components/dist/components/Tabs/Tabs";
16+
import { useInstance } from "context/useInstances";
1917

2018
const tabs: string[] = [
2119
"Overview",
@@ -47,10 +45,7 @@ const InstanceDetail: FC = () => {
4745
error,
4846
refetch: refreshInstance,
4947
isLoading,
50-
} = useQuery({
51-
queryKey: [queryKeys.instances, name, project],
52-
queryFn: () => fetchInstance(name, project),
53-
});
48+
} = useInstance(name, project);
5449

5550
const renderTabs: (string | TabLink)[] = [...tabs];
5651

src/pages/instances/InstanceDetailPanel.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,22 @@ import { FC } from "react";
22
import OpenTerminalBtn from "./actions/OpenTerminalBtn";
33
import OpenConsoleBtn from "./actions/OpenConsoleBtn";
44
import { Button, Icon, List, useNotify } from "@canonical/react-components";
5-
import { useQuery } from "@tanstack/react-query";
6-
import { fetchInstance } from "api/instances";
7-
import { queryKeys } from "util/queryKeys";
85
import usePanelParams from "util/usePanelParams";
96
import InstanceStateActions from "pages/instances/actions/InstanceStateActions";
107
import SidePanel from "components/SidePanel";
118
import InstanceDetailPanelContent from "./InstanceDetailPanelContent";
9+
import { useInstance } from "context/useInstances";
1210

1311
const InstanceDetailPanel: FC = () => {
1412
const notify = useNotify();
1513
const panelParams = usePanelParams();
1614

15+
const enable = panelParams.instance !== null;
1716
const {
1817
data: instance,
1918
error,
2019
isLoading,
21-
} = useQuery({
22-
queryKey: [queryKeys.instances, panelParams.instance, panelParams.project],
23-
queryFn: () =>
24-
fetchInstance(panelParams.instance ?? "", panelParams.project),
25-
enabled: panelParams.instance !== null,
26-
});
20+
} = useInstance(panelParams.instance ?? "", panelParams.project, enable);
2721

2822
if (error) {
2923
notify.failure("Loading instance failed", error);

src/pages/instances/InstanceList.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
TablePagination,
1010
useNotify,
1111
} from "@canonical/react-components";
12-
import { fetchInstances } from "api/instances";
1312
import { useQuery } from "@tanstack/react-query";
1413
import { queryKeys } from "util/queryKeys";
1514
import usePanelParams, { panels } from "util/usePanelParams";
@@ -65,6 +64,7 @@ import { useSettings } from "context/useSettings";
6564
import { isClusteredServer } from "util/settings";
6665
import InstanceUsageMemory from "pages/instances/InstanceUsageMemory";
6766
import InstanceUsageDisk from "pages/instances/InstanceDisk";
67+
import { useInstances } from "context/useInstances";
6868

6969
const loadHidden = () => {
7070
const saved = localStorage.getItem("instanceListHiddenColumns");
@@ -111,14 +111,7 @@ const InstanceList: FC = () => {
111111
return <>Missing project</>;
112112
}
113113

114-
const {
115-
data: instances = [],
116-
error,
117-
isLoading,
118-
} = useQuery({
119-
queryKey: [queryKeys.instances, project],
120-
queryFn: () => fetchInstances(project),
121-
});
114+
const { data: instances = [], error, isLoading } = useInstances(project);
122115

123116
if (error) {
124117
notify.failure("Loading instances failed", error);

src/pages/instances/actions/FreezeInstanceBtn.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useEventQueue } from "context/eventQueue";
99
import { useToastNotification } from "context/toastNotificationProvider";
1010
import ItemName from "components/ItemName";
1111
import InstanceLinkChip from "../InstanceLinkChip";
12+
import { useInstanceEntitlements } from "util/entitlements/instances";
1213

1314
interface Props {
1415
instance: LxdInstance;
@@ -19,6 +20,7 @@ const FreezeInstanceBtn: FC<Props> = ({ instance }) => {
1920
const instanceLoading = useInstanceLoading();
2021
const toastNotify = useToastNotification();
2122
const queryClient = useQueryClient();
23+
const { canUpdateInstanceState } = useInstanceEntitlements(instance);
2224

2325
const clearCache = () => {
2426
void queryClient.invalidateQueries({
@@ -81,10 +83,12 @@ const FreezeInstanceBtn: FC<Props> = ({ instance }) => {
8183
</p>
8284
),
8385
onConfirm: handleFreeze,
84-
confirmButtonLabel: "Freeze",
86+
confirmButtonLabel: canUpdateInstanceState()
87+
? "Freeze"
88+
: "You do not have permission to freeze this instance",
8589
}}
8690
className="has-icon is-dense"
87-
disabled={isDisabled}
91+
disabled={isDisabled || !canUpdateInstanceState()}
8892
shiftClickEnabled
8993
showShiftClickHint
9094
>

src/pages/instances/actions/RestartInstanceBtn.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useEventQueue } from "context/eventQueue";
1010
import { useToastNotification } from "context/toastNotificationProvider";
1111
import ItemName from "components/ItemName";
1212
import InstanceLinkChip from "../InstanceLinkChip";
13+
import { useInstanceEntitlements } from "util/entitlements/instances";
1314

1415
interface Props {
1516
instance: LxdInstance;
@@ -24,6 +25,7 @@ const RestartInstanceBtn: FC<Props> = ({ instance }) => {
2425
const isLoading =
2526
instanceLoading.getType(instance) === "Restarting" ||
2627
instance.status === "Restarting";
28+
const { canUpdateInstanceState } = useInstanceEntitlements(instance);
2729

2830
const instanceLink = <InstanceLinkChip instance={instance} />;
2931

@@ -74,15 +76,17 @@ const RestartInstanceBtn: FC<Props> = ({ instance }) => {
7476
),
7577
onConfirm: handleRestart,
7678
close: () => setForce(false),
77-
confirmButtonLabel: "Restart",
79+
confirmButtonLabel: canUpdateInstanceState()
80+
? "Restart"
81+
: "You do not have permission to restart this instance",
7882
confirmExtra: (
7983
<ConfirmationForce
8084
label="Force restart"
8185
force={[isForce, setForce]}
8286
/>
8387
),
8488
}}
85-
disabled={isDisabled}
89+
disabled={isDisabled || !canUpdateInstanceState()}
8690
shiftClickEnabled
8791
showShiftClickHint
8892
>

0 commit comments

Comments
 (0)