Skip to content

Commit

Permalink
experiment: initial approach for restricted users permission checks
Browse files Browse the repository at this point in the history
Signed-off-by: Mason Hu <[email protected]>
  • Loading branch information
mas-who committed Feb 4, 2025
1 parent a4d4563 commit 6dd5b16
Show file tree
Hide file tree
Showing 20 changed files with 166 additions and 47 deletions.
9 changes: 9 additions & 0 deletions src/api/auth-identities.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ export const fetchIdentities = (): Promise<LxdIdentity[]> => {
});
};

export const fetchCurrentIdentity = (): Promise<LxdIdentity> => {
return new Promise((resolve, reject) => {
fetch(`/1.0/auth/identities/current?recursion=1`)
.then(handleResponse)
.then((data: LxdApiResponse<LxdIdentity>) => resolve(data.metadata))
.catch(reject);
});
};

export const fetchIdentity = (
id: string,
authMethod: string,
Expand Down
18 changes: 14 additions & 4 deletions src/api/instances.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<LxdInstance> => {
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<LxdInstance[]> => {
export const fetchInstances = (
project: string,
isFineGrained: boolean | null,
): Promise<LxdInstance[]> => {
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<LxdInstance[]>) => resolve(data.metadata))
.catch(reject);
Expand Down
23 changes: 23 additions & 0 deletions src/context/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,6 +14,7 @@ interface ContextProps {
isRestricted: boolean;
defaultProject: string;
hasNoProjects: boolean;
isFineGrained: boolean | null;
}

const initialState: ContextProps = {
Expand All @@ -21,6 +24,7 @@ const initialState: ContextProps = {
isRestricted: false,
defaultProject: "default",
hasNoProjects: false,
isFineGrained: null,
};

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

const { hasEntitiesWithEntitlements, isSettingsLoading } =
useSupportedFeatures();

const { data: projects = [], isLoading: isProjectsLoading } = useQuery({
queryKey: [queryKeys.projects],
queryFn: fetchProjects,
Expand All @@ -51,11 +58,26 @@ export const AuthProvider: FC<ProviderProps> = ({ 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 (
<AuthContext.Provider
Expand All @@ -66,6 +88,7 @@ export const AuthProvider: FC<ProviderProps> = ({ children }) => {
isRestricted,
defaultProject,
hasNoProjects: projects.length === 0 && !isProjectsLoading,
isFineGrained: isFineGrained(),
}}
>
{children}
Expand Down
30 changes: 30 additions & 0 deletions src/context/useInstances.tsx
Original file line number Diff line number Diff line change
@@ -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<LxdInstance[]> => {
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<LxdInstance> => {
const { isFineGrained } = useAuth();
return useQuery({
queryKey: [queryKeys.instances, name, project],
queryFn: () => fetchInstance(name, project, isFineGrained),
enabled: enabled && isFineGrained !== null,
});
};
3 changes: 3 additions & 0 deletions src/context/useSupportedFeatures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@ export const useSupportedFeatures = () => {
hasClusterInternalCustomVolumeCopy: apiExtensions.has(
"cluster_internal_custom_volume_copy",
),
hasEntitiesWithEntitlements: apiExtensions.has(
"entities_with_entitlements",
),
};
};
9 changes: 2 additions & 7 deletions src/pages/instances/InstanceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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",
Expand Down Expand Up @@ -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];

Expand Down
12 changes: 3 additions & 9 deletions src/pages/instances/InstanceDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 2 additions & 9 deletions src/pages/instances/InstanceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions src/pages/instances/actions/FreezeInstanceBtn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +20,7 @@ const FreezeInstanceBtn: FC<Props> = ({ instance }) => {
const instanceLoading = useInstanceLoading();
const toastNotify = useToastNotification();
const queryClient = useQueryClient();
const { canUpdateInstanceState } = useInstanceEntitlements(instance);

const clearCache = () => {
void queryClient.invalidateQueries({
Expand Down Expand Up @@ -81,10 +83,12 @@ const FreezeInstanceBtn: FC<Props> = ({ instance }) => {
</p>
),
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
>
Expand Down
8 changes: 6 additions & 2 deletions src/pages/instances/actions/RestartInstanceBtn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +25,7 @@ const RestartInstanceBtn: FC<Props> = ({ instance }) => {
const isLoading =
instanceLoading.getType(instance) === "Restarting" ||
instance.status === "Restarting";
const { canUpdateInstanceState } = useInstanceEntitlements(instance);

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

Expand Down Expand Up @@ -74,15 +76,17 @@ const RestartInstanceBtn: FC<Props> = ({ instance }) => {
),
onConfirm: handleRestart,
close: () => setForce(false),
confirmButtonLabel: "Restart",
confirmButtonLabel: canUpdateInstanceState()
? "Restart"
: "You do not have permission to restart this instance",
confirmExtra: (
<ConfirmationForce
label="Force restart"
force={[isForce, setForce]}
/>
),
}}
disabled={isDisabled}
disabled={isDisabled || !canUpdateInstanceState()}
shiftClickEnabled
showShiftClickHint
>
Expand Down
10 changes: 8 additions & 2 deletions src/pages/instances/actions/StartInstanceBtn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,30 @@ 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;
}

const StartInstanceBtn: FC<Props> = ({ instance }) => {
const { handleStart, isLoading, isDisabled } = useInstanceStart(instance);
const { canUpdateInstanceState } = useInstanceEntitlements(instance);

return (
<Button
appearance="base"
hasIcon
dense={true}
disabled={isDisabled}
disabled={isDisabled || !canUpdateInstanceState()}
onClick={handleStart}
type="button"
aria-label={isLoading ? "Starting" : "Start"}
title="Start"
title={
canUpdateInstanceState()
? "Start"
: "You do not have permission to start this instance"
}
>
<Icon
className={classnames({ "u-animation--spin": isLoading })}
Expand Down
8 changes: 6 additions & 2 deletions src/pages/instances/actions/StopInstanceBtn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +22,7 @@ const StopInstanceBtn: FC<Props> = ({ instance }) => {
const toastNotify = useToastNotification();
const [isForce, setForce] = useState(false);
const queryClient = useQueryClient();
const { canUpdateInstanceState } = useInstanceEntitlements(instance);

const clearCache = () => {
void queryClient.invalidateQueries({
Expand Down Expand Up @@ -76,7 +78,7 @@ const StopInstanceBtn: FC<Props> = ({ instance }) => {
<ConfirmationButton
appearance="base"
loading={isLoading}
disabled={isDisabled}
disabled={isDisabled || !canUpdateInstanceState()}
confirmationModalProps={{
title: "Confirm stop",
children: (
Expand All @@ -89,7 +91,9 @@ const StopInstanceBtn: FC<Props> = ({ instance }) => {
),
onConfirm: handleStop,
close: () => setForce(false),
confirmButtonLabel: "Stop",
confirmButtonLabel: canUpdateInstanceState()
? "Stop"
: "You do not have permission to stop this instance",
}}
className="has-icon is-dense"
shiftClickEnabled
Expand Down
Loading

0 comments on commit 6dd5b16

Please sign in to comment.