Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: initial approach for restricted users permission checks [WD-18836] #1082

Merged
merged 1 commit into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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