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: restricted permissions for image actions [WD-18905] #1100

Merged
merged 9 commits into from
Feb 13, 2025
2 changes: 1 addition & 1 deletion src/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "context/auth";
import { EventQueueProvider } from "context/eventQueue";
import { InstanceLoadingProvider } from "context/instanceLoading";
import { ProjectProvider } from "context/project";
import { ProjectProvider } from "context/useCurrentProject";
import Events from "pages/instances/Events";
import App from "./App";
import ErrorBoundary from "components/ErrorBoundary";
Expand Down
26 changes: 21 additions & 5 deletions src/api/images.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,29 @@ import { EventQueue } from "context/eventQueue";
import type { LxdInstance } from "types/instance";
import type { UploadState } from "types/storage";
import axios, { AxiosResponse } from "axios";
import { withEntitlementsQuery } from "util/entitlements/api";

export const fetchImageList = (project?: string): Promise<LxdImage[]> => {
const url =
"/1.0/images?recursion=1" +
(project ? `&project=${project}` : "&all-projects=1");
const imageEntitlements = ["can_delete"];

export const fetchImagesInProject = (
project: string,
isFineGrained: boolean | null,
): Promise<LxdImage[]> => {
const entitlements = `&${withEntitlementsQuery(isFineGrained, imageEntitlements)}`;
return new Promise((resolve, reject) => {
fetch(`/1.0/images?recursion=1&project=${project}${entitlements}`)
.then(handleResponse)
.then((data: LxdApiResponse<LxdImage[]>) => resolve(data.metadata))
.catch(reject);
});
};

export const fetchImagesInAllProjects = (
isFineGrained: boolean | null,
): Promise<LxdImage[]> => {
const entitlements = `&${withEntitlementsQuery(isFineGrained, imageEntitlements)}`;
return new Promise((resolve, reject) => {
fetch(url)
fetch(`/1.0/images?recursion=1&all-projects=1${entitlements}`)
.then(handleResponse)
.then((data: LxdApiResponse<LxdImage[]>) => resolve(data.metadata))
.catch(reject);
Expand Down
22 changes: 18 additions & 4 deletions src/api/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,33 @@ import { handleEtagResponse, handleResponse } from "util/helpers";
import type { LxdProject } from "types/project";
import type { LxdApiResponse } from "types/apiResponse";
import type { LxdOperationResponse } from "types/operation";
import { withEntitlementsQuery } from "util/entitlements/api";

export const fetchProjects = (): Promise<LxdProject[]> => {
const projectEntitlements = [
"can_create_images",
"can_create_image_aliases",
"can_create_instances",
];

export const fetchProjects = (
isFineGrained: boolean | null,
): Promise<LxdProject[]> => {
const entitlements = `&${withEntitlementsQuery(isFineGrained, projectEntitlements)}`;
return new Promise((resolve, reject) => {
fetch(`/1.0/projects?recursion=1`)
fetch(`/1.0/projects?recursion=1${entitlements}`)
.then(handleResponse)
.then((data: LxdApiResponse<LxdProject[]>) => resolve(data.metadata))
.catch(reject);
});
};

export const fetchProject = (name: string): Promise<LxdProject> => {
export const fetchProject = (
name: string,
isFineGrained: boolean | null,
): Promise<LxdProject> => {
const entitlements = `?${withEntitlementsQuery(isFineGrained, projectEntitlements)}`;
return new Promise((resolve, reject) => {
fetch(`/1.0/projects/${name}`)
fetch(`/1.0/projects/${name}${entitlements}`)
.then(handleEtagResponse)
.then((data) => resolve(data as LxdProject))
.catch(reject);
Expand Down
4 changes: 2 additions & 2 deletions src/components/Logo.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { FC } from "react";
import { useProject } from "context/project";
import { useCurrentProject } from "context/useCurrentProject";
import { NavLink } from "react-router-dom";
import { useSettings } from "context/useSettings";

const Logo: FC = () => {
const { project, isLoading } = useProject();
const { project, isLoading } = useCurrentProject();
const { data: settings } = useSettings();

const isMicroCloud = Boolean(settings?.config?.["user.microcloud"]);
Expand Down
4 changes: 2 additions & 2 deletions src/components/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import classnames from "classnames";
import Logo from "./Logo";
import ProjectSelector from "pages/projects/ProjectSelector";
import { getElementAbsoluteHeight, isWidthBelow, logout } from "util/helpers";
import { useProject } from "context/project";
import { useCurrentProject } from "context/useCurrentProject";
import { useMenuCollapsed } from "context/menuCollapsed";
import { useDocs } from "context/useDocs";
import NavLink from "components/NavLink";
Expand Down Expand Up @@ -37,7 +37,7 @@ const Navigation: FC = () => {
const { isRestricted, isOidc } = useAuth();
const docBaseLink = useDocs();
const { menuCollapsed, setMenuCollapsed } = useMenuCollapsed();
const { project, isLoading } = useProject();
const { project, isLoading } = useCurrentProject();
const [projectName, setProjectName] = useState(
project && !isLoading ? project.name : "default",
);
Expand Down
14 changes: 6 additions & 8 deletions src/components/UsedByItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { FC } from "react";
import ResourceLink from "./ResourceLink";
import { LxdUsedBy } from "util/usedBy";
import { ResourceIconType } from "./ResourceIcon";
import { useQuery } from "@tanstack/react-query";
import { fetchImageList } from "api/images";
import { queryKeys } from "util/queryKeys";
import { useImagesInProject } from "context/useImages";

interface Props {
item: LxdUsedBy;
Expand All @@ -21,11 +19,11 @@ const UsedByItem: FC<Props> = ({
to,
projectLinkDetailPage = "instances",
}) => {
const { data: images = [] } = useQuery({
queryKey: [queryKeys.images],
queryFn: () => fetchImageList(activeProject),
enabled: type === "image",
});
const isImageQueryEnabled = type === "image";
const { data: images = [] } = useImagesInProject(
activeProject,
isImageQueryEnabled,
);

const image = images.find((image) => image.fingerprint === item.name);
const label = image?.properties?.description || item.name;
Expand Down
4 changes: 2 additions & 2 deletions src/components/forms/CpuLimitSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FC } from "react";
import { RadioInput } from "@canonical/react-components";
import { CpuLimit, CPU_LIMIT_TYPE } from "types/limits";
import CpuLimitInput from "components/forms/CpuLimitInput";
import { useProject } from "context/project";
import { useCurrentProject } from "context/useCurrentProject";

interface Props {
cpuLimit?: CpuLimit;
Expand All @@ -11,7 +11,7 @@ interface Props {
}

const CpuLimitSelector: FC<Props> = ({ cpuLimit, setCpuLimit, help }) => {
const { project } = useProject();
const { project } = useCurrentProject();

if (!cpuLimit) {
return null;
Expand Down
4 changes: 2 additions & 2 deletions src/components/forms/InstanceSnapshotsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import ScrollableConfigurationTable from "components/forms/ScrollableConfigurati
import { getInstanceKey } from "util/instanceConfigFields";
import { optionRenderer } from "util/formFields";
import SnapshotScheduleInput from "components/SnapshotScheduleInput";
import { useProject } from "context/project";
import { useCurrentProject } from "context/useCurrentProject";
import { isSnapshotsDisabled } from "util/snapshots";
import SnapshotDiabledWarningLink from "components/SnapshotDiabledWarningLink";

Expand All @@ -37,7 +37,7 @@ interface Props {
}

const InstanceSnapshotsForm: FC<Props> = ({ formik }) => {
const { project } = useProject();
const { project } = useCurrentProject();
const snapshotDisabled = isSnapshotsDisabled(project);

return (
Expand Down
4 changes: 2 additions & 2 deletions src/components/forms/MemoryLimitSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { FC } from "react";
import { Input, RadioInput, Select } from "@canonical/react-components";
import { BYTES_UNITS, MemoryLimit, MEM_LIMIT_TYPE } from "types/limits";
import MemoryLimitAvailable from "components/forms/MemoryLimitAvailable";
import { useProject } from "context/project";
import { useCurrentProject } from "context/useCurrentProject";

interface Props {
memoryLimit?: MemoryLimit;
setMemoryLimit: (memoryLimit: MemoryLimit) => void;
}

const MemoryLimitSelector: FC<Props> = ({ memoryLimit, setMemoryLimit }) => {
const { project } = useProject();
const { project } = useCurrentProject();

if (!memoryLimit) {
return null;
Expand Down
43 changes: 21 additions & 22 deletions src/context/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { createContext, FC, ReactNode, useContext } from "react";
import { useQuery } from "@tanstack/react-query";
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";
Expand Down Expand Up @@ -36,15 +35,29 @@ interface ProviderProps {
}

export const AuthProvider: FC<ProviderProps> = ({ children }) => {
const { data: settings, isLoading } = useSettings();

const { hasEntitiesWithEntitlements, isSettingsLoading } =
const { hasEntitiesWithEntitlements, isSettingsLoading, settings } =
useSupportedFeatures();

const { data: currentIdentity, isLoading: isIdentityLoading } = useQuery({
queryKey: [queryKeys.currentIdentity],
queryFn: fetchCurrentIdentity,
retry: false, // avoid retry for older versions of lxd less than 5.21 due to missing endpoint
});

const isFineGrained = () => {
if (isSettingsLoading) {
return null;
}
if (hasEntitiesWithEntitlements) {
return currentIdentity?.fine_grained ?? null;
}
return false;
};

const { data: projects = [], isLoading: isProjectsLoading } = useQuery({
queryKey: [queryKeys.projects],
queryFn: fetchProjects,
enabled: settings?.auth === "trusted",
queryFn: () => fetchProjects(isFineGrained()),
enabled: settings?.auth === "trusted" && isFineGrained() !== null,
});

const defaultProject =
Expand All @@ -60,26 +73,11 @@ 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;
};

const serverEntitlements = (currentIdentity?.effective_permissions || [])
.filter((permission) => permission.entity_type === "server")
Expand All @@ -90,7 +88,8 @@ export const AuthProvider: FC<ProviderProps> = ({ children }) => {
value={{
isAuthenticated: (settings && settings.auth !== "untrusted") ?? false,
isOidc: settings?.auth_user_method === "oidc",
isAuthLoading: isLoading,
isAuthLoading:
isSettingsLoading || isIdentityLoading || isProjectsLoading,
isRestricted,
defaultProject,
hasNoProjects: projects.length === 0 && !isProjectsLoading,
Expand Down
15 changes: 5 additions & 10 deletions src/context/project.tsx → src/context/useCurrentProject.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { createContext, FC, ReactNode, useContext } from "react";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { fetchProject } from "api/projects";
import type { LxdProject } from "types/project";
import { useLocation } from "react-router-dom";
import { useProject } from "./useProjects";

interface ContextProps {
project?: LxdProject;
Expand All @@ -26,12 +24,9 @@ export const ProjectProvider: FC<ProviderProps> = ({ children }) => {
const url = location.pathname;
const project = url.startsWith("/ui/project/") ? url.split("/")[3] : "";

const { data, isLoading } = useQuery({
queryKey: [queryKeys.projects, project],
queryFn: () => fetchProject(project),
retry: false,
enabled: project.length > 0,
});
const enabled = project.length > 0;
const retry = false;
const { data, isLoading } = useProject(project, enabled, retry);

return (
<ProjectContext.Provider
Expand All @@ -45,6 +40,6 @@ export const ProjectProvider: FC<ProviderProps> = ({ children }) => {
);
};

export function useProject() {
export function useCurrentProject() {
return useContext(ProjectContext);
}
29 changes: 29 additions & 0 deletions src/context/useImages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { UseQueryResult } from "@tanstack/react-query";
import { useAuth } from "./auth";
import { fetchImagesInAllProjects, fetchImagesInProject } from "api/images";
import type { LxdImage } from "types/image";

export const useImagesInProject = (
project: string,
enabled?: boolean,
): UseQueryResult<LxdImage[]> => {
const { isFineGrained } = useAuth();
return useQuery({
queryKey: [queryKeys.images, project],
queryFn: () => fetchImagesInProject(project, isFineGrained),
enabled: (enabled ?? true) && isFineGrained !== null,
});
};

export const useImagesInAllProjects = (
enabled?: boolean,
): UseQueryResult<LxdImage[]> => {
const { isFineGrained } = useAuth();
return useQuery({
queryKey: [queryKeys.images],
queryFn: () => fetchImagesInAllProjects(isFineGrained),
enabled: (enabled ?? true) && isFineGrained !== null,
});
};
28 changes: 28 additions & 0 deletions src/context/useProjects.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { LxdProject } from "types/project";
import { useAuth } from "./auth";
import { queryKeys } from "util/queryKeys";
import { fetchProject, fetchProjects } from "api/projects";

export const useProjects = (): UseQueryResult<LxdProject[]> => {
const { isFineGrained } = useAuth();
return useQuery({
queryKey: [queryKeys.projects],
queryFn: () => fetchProjects(isFineGrained),
enabled: isFineGrained !== null,
});
};

export const useProject = (
project: string,
enabled?: boolean,
retry?: boolean,
): UseQueryResult<LxdProject> => {
const { isFineGrained } = useAuth();
return useQuery({
queryKey: [queryKeys.projects, project],
queryFn: () => fetchProject(project, isFineGrained),
retry: retry ?? true,
enabled: (enabled ?? true) && isFineGrained !== null,
});
};
4 changes: 2 additions & 2 deletions src/pages/images/CustomIsoSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { loadIsoVolumes } from "context/loadIsoVolumes";
import { queryKeys } from "util/queryKeys";
import Loader from "components/Loader";
import { useProject } from "context/project";
import { useCurrentProject } from "context/useCurrentProject";
import type { LxdImageType, RemoteImage } from "types/image";
import type { IsoImage } from "types/iso";
import { useSupportedFeatures } from "context/useSupportedFeatures";
Expand All @@ -23,7 +23,7 @@ const CustomIsoSelector: FC<Props> = ({
onUpload,
onCancel,
}) => {
const { project } = useProject();
const { project } = useCurrentProject();
const projectName = project?.name ?? "";
const { hasStorageVolumesAll } = useSupportedFeatures();

Expand Down
Loading