Skip to content

Commit

Permalink
feat: restricted permissions for networks [WD-18903] (canonical#1114)
Browse files Browse the repository at this point in the history
  • Loading branch information
mas-who authored Feb 20, 2025
2 parents fe81d93 + 161147a commit e751537
Show file tree
Hide file tree
Showing 27 changed files with 341 additions and 155 deletions.
43 changes: 36 additions & 7 deletions src/api/networks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,24 @@ import type { LxdApiResponse } from "types/apiResponse";
import { areNetworksEqual } from "util/networks";
import type { ClusterSpecificValues } from "components/ClusterSpecificSelect";
import type { LxdClusterMember } from "types/cluster";
import { withEntitlementsQuery } from "util/entitlements/api";

const networkEntitlements = ["can_edit", "can_delete"];

export const fetchNetworks = (
project: string,
isFineGrained: boolean | null,
target?: string,
): Promise<LxdNetwork[]> => {
const targetParam = target ? `&target=${target}` : "";
const entitlements = `&${withEntitlementsQuery(
isFineGrained,
networkEntitlements,
)}`;
return new Promise((resolve, reject) => {
fetch(`/1.0/networks?project=${project}&recursion=1${targetParam}`)
fetch(
`/1.0/networks?project=${project}&recursion=1${targetParam}${entitlements}`,
)
.then(handleResponse)
.then((data: LxdApiResponse<LxdNetwork[]>) => {
const filteredNetworks = data.metadata.filter(
Expand All @@ -36,11 +46,12 @@ export const fetchNetworks = (
export const fetchNetworksFromClusterMembers = (
project: string,
clusterMembers: LxdClusterMember[],
isFineGrained: boolean | null,
): Promise<LXDNetworkOnClusterMember[]> => {
return new Promise((resolve, reject) => {
Promise.allSettled(
clusterMembers.map((member) => {
return fetchNetworks(project, member.server_name);
return fetchNetworks(project, isFineGrained, member.server_name);
}),
)
.then((results) => {
Expand All @@ -66,11 +77,18 @@ export const fetchNetworksFromClusterMembers = (
export const fetchNetwork = (
name: string,
project: string,
isFineGrained: boolean | null,
target?: string,
): Promise<LxdNetwork> => {
const targetParam = target ? `&target=${target}` : "";
const entitlements = `&${withEntitlementsQuery(
isFineGrained,
networkEntitlements,
)}`;
return new Promise((resolve, reject) => {
fetch(`/1.0/networks/${name}?project=${project}${targetParam}`)
fetch(
`/1.0/networks/${name}?project=${project}${targetParam}${entitlements}`,
)
.then(handleEtagResponse)
.then((data) => resolve(data as LxdNetwork))
.catch(reject);
Expand All @@ -81,11 +99,12 @@ export const fetchNetworkFromClusterMembers = (
name: string,
project: string,
clusterMembers: LxdClusterMember[],
isFineGrained: boolean | null,
): Promise<LXDNetworkOnClusterMember[]> => {
return new Promise((resolve, reject) => {
Promise.allSettled(
clusterMembers.map((member) => {
return fetchNetwork(name, project, member.server_name);
return fetchNetwork(name, project, isFineGrained, member.server_name);
}),
)
.then((results) => {
Expand Down Expand Up @@ -174,7 +193,12 @@ export const createNetwork = (
// when creating a network on localhost the request will get cancelled
// check manually if creation was successful
if (e.message === "Failed to fetch") {
const newNetwork = await fetchNetwork(network.name ?? "", project);
const newNetwork = await fetchNetwork(
network.name ?? "",
project,
false,
target,
);
if (newNetwork) {
resolve();
}
Expand Down Expand Up @@ -207,7 +231,12 @@ export const updateNetwork = (
// when updating a network on localhost the request will get cancelled
// check manually if the edit was successful
if (e.message === "Failed to fetch") {
const newNetwork = await fetchNetwork(network.name ?? "", project);
const newNetwork = await fetchNetwork(
network.name ?? "",
project,
false,
target,
);
if (areNetworksEqual(network, newNetwork)) {
resolve();
}
Expand Down Expand Up @@ -269,7 +298,7 @@ export const renameNetwork = (
// when renaming a network on localhost the request will get cancelled
// check manually if renaming was successful
if (e.message === "Failed to fetch") {
const renamedNetwork = await fetchNetwork(newName, project);
const renamedNetwork = await fetchNetwork(newName, project, false);
if (renamedNetwork) {
resolve();
}
Expand Down
1 change: 1 addition & 0 deletions src/api/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const projectEntitlements = [
"can_create_image_aliases",
"can_create_instances",
"can_create_storage_volumes",
"can_create_networks",
];

export const fetchProjects = (
Expand Down
12 changes: 10 additions & 2 deletions src/components/ClusterSpecificSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface Props {
canToggleSpecific?: boolean;
isDefaultSpecific?: boolean;
clusterMemberLinkTarget?: (member: string) => string;
disableReason?: string;
}

const ClusterSpecificSelect: FC<Props> = ({
Expand All @@ -40,6 +41,7 @@ const ClusterSpecificSelect: FC<Props> = ({
canToggleSpecific = true,
isDefaultSpecific = false,
clusterMemberLinkTarget = () => "/ui/cluster",
disableReason,
}) => {
const [isSpecific, setIsSpecific] = useState(isDefaultSpecific);

Expand Down Expand Up @@ -131,7 +133,10 @@ const ClusterSpecificSelect: FC<Props> = ({
{isReadOnly ? (
<>
{activeValue}
<FormEditButton toggleReadOnly={toggleReadOnly} />
<FormEditButton
toggleReadOnly={toggleReadOnly}
disableReason={disableReason}
/>
</>
) : (
<Select
Expand Down Expand Up @@ -159,7 +164,10 @@ const ClusterSpecificSelect: FC<Props> = ({
{isReadOnly ? (
<>
{firstValue}
<FormEditButton toggleReadOnly={toggleReadOnly} />
<FormEditButton
toggleReadOnly={toggleReadOnly}
disableReason={disableReason}
/>
</>
) : (
<Select
Expand Down
6 changes: 4 additions & 2 deletions src/components/FormEditButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ import { Button, Icon } from "@canonical/react-components";

interface Props {
toggleReadOnly: () => void;
disableReason?: string;
}

const FormEditButton: FC<Props> = ({ toggleReadOnly }) => {
const FormEditButton: FC<Props> = ({ toggleReadOnly, disableReason }) => {
return (
<Button
onClick={toggleReadOnly}
className="u-no-margin--bottom"
type="button"
appearance="base"
title="Edit"
title={disableReason ?? "Edit"}
hasIcon
disabled={!!disableReason}
>
<Icon name="edit" />
</Button>
Expand Down
9 changes: 2 additions & 7 deletions src/components/NetworkListTable.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { FC } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { MainTable, Notification } from "@canonical/react-components";
import Loader from "components/Loader";
import { fetchNetworks } from "api/networks";
import { isNicDevice } from "util/devices";
import ResourceLink from "components/ResourceLink";
import { useParams } from "react-router-dom";
import type { LxdDevices } from "types/device";
import { useNetworks } from "context/useNetworks";

interface Props {
onFailure: (title: string, e: unknown) => void;
Expand All @@ -21,10 +19,7 @@ const NetworkListTable: FC<Props> = ({ onFailure, devices }) => {
data: networks = [],
error,
isLoading,
} = useQuery({
queryKey: [queryKeys.projects, project, queryKeys.networks],
queryFn: () => fetchNetworks(project as string),
});
} = useNetworks(project as string);

if (error) {
onFailure("Loading networks failed", error);
Expand Down
7 changes: 2 additions & 5 deletions src/components/forms/NetworkDevicesForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from "@canonical/react-components";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { fetchNetworks } from "api/networks";
import type { LxdNicDevice } from "types/device";
import { InstanceAndProfileFormikProps } from "./instanceAndProfileFormValues";
import ScrollableConfigurationTable from "components/forms/ScrollableConfigurationTable";
Expand All @@ -23,6 +22,7 @@ import { isNicDeviceNameMissing } from "util/instanceValidation";
import { ensureEditMode } from "util/instanceEdit";
import { getExistingDeviceNames } from "util/devices";
import { focusField } from "util/formFields";
import { useNetworks } from "context/useNetworks";

interface Props {
formik: InstanceAndProfileFormikProps;
Expand All @@ -49,10 +49,7 @@ const NetworkDevicesForm: FC<Props> = ({ formik, project }) => {
data: networks = [],
isLoading: isNetworkLoading,
error: networkError,
} = useQuery({
queryKey: [queryKeys.projects, project, queryKeys.networks],
queryFn: () => fetchNetworks(project),
});
} = useNetworks(project);

useEffect(() => {
if (networkError) {
Expand Down
9 changes: 3 additions & 6 deletions src/components/forms/YamlForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ import { FC, ReactNode, useEffect, useRef, useState } from "react";
import { Editor, loader } from "@monaco-editor/react";
import { updateMaxHeight } from "util/updateMaxHeight";
import useEventListener from "util/useEventListener";
import {
editor,
IMarkdownString,
} from "monaco-editor/esm/vs/editor/editor.api";
import { editor } from "monaco-editor/esm/vs/editor/editor.api";
import IStandaloneCodeEditor = editor.IStandaloneCodeEditor;
import classnames from "classnames";

Expand All @@ -19,7 +16,7 @@ interface Props {
children?: ReactNode;
autoResize?: boolean;
readOnly?: boolean;
readOnlyMessage?: IMarkdownString;
readOnlyMessage?: string;
}

const YamlForm: FC<Props> = ({
Expand Down Expand Up @@ -75,7 +72,7 @@ const YamlForm: FC<Props> = ({
},
overviewRulerLanes: 0,
readOnly: readOnly,
readOnlyMessage: readOnlyMessage,
readOnlyMessage: { value: readOnlyMessage ?? "" },
}}
onMount={(editor: IStandaloneCodeEditor) => {
setEditor(editor);
Expand Down
95 changes: 95 additions & 0 deletions src/context/useNetworks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "./auth";
import {
fetchNetwork,
fetchNetworkFromClusterMembers,
fetchNetworks,
fetchNetworksFromClusterMembers,
} from "api/networks";
import { LxdNetwork, LXDNetworkOnClusterMember } from "types/network";
import { useClusterMembers } from "./useClusterMembers";

export const useNetworks = (
project: string,
target?: string,
enabled?: boolean,
): UseQueryResult<LxdNetwork[]> => {
const { isFineGrained } = useAuth();

const queryKey = [queryKeys.projects, project, queryKeys.networks];
if (target) {
queryKey.push(queryKeys.members);
queryKey.push(target);
}

return useQuery({
queryKey,
queryFn: () => fetchNetworks(project, isFineGrained, target),
enabled: (enabled ?? true) && isFineGrained !== null,
});
};

export const useNetwork = (
network: string,
project: string,
target?: string,
enabled?: boolean,
): UseQueryResult<LxdNetwork> => {
const { isFineGrained } = useAuth();

const queryKey = [queryKeys.projects, project, queryKeys.networks, network];
if (target) {
queryKey.push(queryKeys.members);
queryKey.push(target);
}

return useQuery({
queryKey,
queryFn: () => fetchNetwork(network, project, isFineGrained, target),
enabled: (enabled ?? true) && isFineGrained !== null,
});
};

export const useNetworksFromClusterMembers = (
project: string,
): UseQueryResult<LXDNetworkOnClusterMember[]> => {
const { isFineGrained } = useAuth();
const { data: clusterMembers = [] } = useClusterMembers();

return useQuery({
queryKey: [queryKeys.networks, project, queryKeys.cluster],
queryFn: () =>
fetchNetworksFromClusterMembers(project, clusterMembers, isFineGrained),
enabled: isFineGrained !== null && clusterMembers.length > 0,
});
};

export const useNetworkFromClusterMembers = (
network: string,
project: string,
enabled?: boolean,
): UseQueryResult<LXDNetworkOnClusterMember[]> => {
const { isFineGrained } = useAuth();
const { data: clusterMembers = [] } = useClusterMembers();

return useQuery({
queryKey: [
queryKeys.projects,
project,
queryKeys.networks,
network,
queryKeys.cluster,
],
queryFn: () =>
fetchNetworkFromClusterMembers(
network,
project,
clusterMembers,
isFineGrained,
),
enabled:
(enabled ?? true) && isFineGrained !== null && clusterMembers.length > 0,
});
};
2 changes: 1 addition & 1 deletion src/pages/instances/EditInstance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ const EditInstance: FC<Props> = ({ instance }) => {
void formik.setFieldValue("yaml", yaml);
}}
readOnly={!!formik.values.editRestriction}
readOnlyMessage={{ value: formik.values.editRestriction ?? "" }}
readOnlyMessage={formik.values.editRestriction}
>
<YamlNotification
entity="instance"
Expand Down
Loading

0 comments on commit e751537

Please sign in to comment.