Skip to content

Commit

Permalink
feat: restricted permissions for project edit page (canonical#1119)
Browse files Browse the repository at this point in the history
  • Loading branch information
mas-who authored Feb 21, 2025
2 parents b7ddc2a + 5e41ac4 commit 56d5d58
Show file tree
Hide file tree
Showing 10 changed files with 127 additions and 51 deletions.
2 changes: 2 additions & 0 deletions src/api/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const projectEntitlements = [
"can_create_networks",
"can_create_profiles",
"can_create_storage_volumes",
"can_delete",
"can_edit",
];

export const fetchProjects = (
Expand Down
4 changes: 3 additions & 1 deletion src/context/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export const AuthProvider: FC<ProviderProps> = ({ children }) => {
const certificate = certificates.find(
(certificate) => certificate.fingerprint === fingerprint,
);
const isRestricted = certificate?.restricted ?? defaultProject !== "default";
const isRestricted =
isFineGrained() !== true &&
(certificate?.restricted ?? defaultProject !== "default");

const serverEntitlements = (currentIdentity?.effective_permissions || [])
.filter((permission) => permission.entity_type === "server")
Expand Down
7 changes: 6 additions & 1 deletion src/pages/projects/EditProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useSupportedFeatures } from "context/useSupportedFeatures";
import FormSubmitBtn from "components/forms/FormSubmitBtn";
import ResourceLink from "components/ResourceLink";
import { useProfile } from "context/useProfiles";
import { useProjectEntitlements } from "util/entitlements/projects";

interface Props {
project: LxdProject;
Expand All @@ -38,6 +39,7 @@ const EditProject: FC<Props> = ({ project }) => {
const { section } = useParams<{ section?: string }>();
const { hasProjectsNetworksZones, hasStorageBuckets } =
useSupportedFeatures();
const { canEditProject } = useProjectEntitlements();

const { data: profile } = useProfile("default", project.name);
const updateFormHeight = () => {
Expand All @@ -50,7 +52,10 @@ const EditProject: FC<Props> = ({ project }) => {
name: Yup.string().required(),
});

const initialValues = getProjectEditValues(project, profile);
const editRestriction = canEditProject(project)
? undefined
: "You do not have permission to edit this project";
const initialValues = getProjectEditValues(project, profile, editRestriction);

const formik: FormikProps<ProjectFormValues> = useFormik({
initialValues: initialValues,
Expand Down
20 changes: 15 additions & 5 deletions src/pages/projects/ProjectConfigurationHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useEventQueue } from "context/eventQueue";
import { useDocs } from "context/useDocs";
import { useToastNotification } from "context/toastNotificationProvider";
import ResourceLink from "components/ResourceLink";
import { useProjectEntitlements } from "util/entitlements/projects";

interface Props {
project: LxdProject;
Expand All @@ -23,6 +24,7 @@ const ProjectConfigurationHeader: FC<Props> = ({ project }) => {
const navigate = useNavigate();
const toastNotify = useToastNotification();
const controllerState = useState<AbortController | null>(null);
const { canEditProject } = useProjectEntitlements();

const RenameSchema = Yup.object().shape({
name: Yup.string()
Expand Down Expand Up @@ -90,6 +92,18 @@ const ProjectConfigurationHeader: FC<Props> = ({ project }) => {
},
});

const getRenameDisabledReason = () => {
if (!canEditProject(project)) {
return "You do not have permission to rename this project";
}

if (project.name === "default") {
return "Cannot rename the default project";
}

return undefined;
};

return (
<RenameHeader
name={project.name}
Expand All @@ -102,11 +116,7 @@ const ProjectConfigurationHeader: FC<Props> = ({ project }) => {
Project configuration
</HelpLink>,
]}
renameDisabledReason={
project.name === "default"
? "Cannot rename the default project"
: undefined
}
renameDisabledReason={getRenameDisabledReason()}
controls={<DeleteProjectBtn project={project} />}
isLoaded={Boolean(project)}
formik={formik}
Expand Down
8 changes: 8 additions & 0 deletions src/pages/projects/ProjectSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useNavigate } from "react-router-dom";
import ProjectSelectorList from "pages/projects/ProjectSelectorList";
import { defaultFirst } from "util/helpers";
import { useProjects } from "context/useProjects";
import { useServerEntitlements } from "util/entitlements/server";

interface Props {
activeProject: string;
Expand All @@ -17,6 +18,7 @@ interface Props {
const ProjectSelector: FC<Props> = ({ activeProject }): React.JSX.Element => {
const navigate = useNavigate();
const searchRef = useRef<HTMLInputElement>(null);
const { canCreateProjects } = useServerEntitlements();

const { data: projects = [] } = useProjects();

Expand Down Expand Up @@ -62,6 +64,12 @@ const ProjectSelector: FC<Props> = ({ activeProject }): React.JSX.Element => {
onClick={() => navigate("/ui/projects/create")}
className="p-contextual-menu__link"
hasIcon
disabled={!canCreateProjects()}
title={
canCreateProjects()
? ""
: "You do not have permission to create projects"
}
>
<Icon name="plus" light />
<span>Create project</span>
Expand Down
7 changes: 6 additions & 1 deletion src/pages/projects/actions/DeleteProjectBtn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useToastNotification } from "context/toastNotificationProvider";
import { filterUsedByType } from "util/usedBy";
import { ResourceType } from "util/resourceDetails";
import ResourceLabel from "components/ResourceLabel";
import { useProjectEntitlements } from "util/entitlements/projects";

interface Props {
project: LxdProject;
Expand Down Expand Up @@ -82,10 +83,14 @@ const DeleteProjectBtn: FC<Props> = ({ project }) => {
const queryClient = useQueryClient();
const [isLoading, setLoading] = useState(false);
const navigate = useNavigate();
const { canDeleteProject } = useProjectEntitlements();

const isDefaultProject = project.name === "default";
const isEmpty = isProjectEmpty(project);
const getHoverText = () => {
if (!canDeleteProject(project)) {
return "You do not have permission to delete this project";
}
if (isDefaultProject) {
return "The default project cannot be deleted";
}
Expand Down Expand Up @@ -125,7 +130,7 @@ const DeleteProjectBtn: FC<Props> = ({ project }) => {
"has-icon": !isSmallScreen,
})}
loading={isLoading}
disabled={isDefaultProject || !isEmpty}
disabled={!canDeleteProject(project) || isDefaultProject || !isEmpty}
confirmationModalProps={{
title: "Confirm delete",
confirmButtonLabel: "Delete",
Expand Down
96 changes: 62 additions & 34 deletions src/pages/projects/forms/ProjectDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
formik.handleChange(e);
}}
value={formik.values.description}
disabled={!!formik.values.editRestriction}
title={formik.values.editRestriction}
/>
<StoragePoolSelector
value={formik.values.default_instance_storage_pool}
Expand Down Expand Up @@ -178,9 +180,10 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
/>
<div
title={
isDefaultProject
formik.values.editRestriction ??
(isDefaultProject
? "Custom features are immutable on the default project"
: ""
: "")
}
>
<Select
Expand Down Expand Up @@ -209,6 +212,7 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
},
]}
disabled={
!!formik.values.editRestriction ||
isDefaultProject ||
(isNonEmpty && hadFeaturesNetwork) ||
(isNonEmpty && hadFeaturesNetworkZones)
Expand All @@ -230,7 +234,11 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
);
}}
checked={formik.values.features_images}
disabled={isDefaultProject || isNonEmpty}
disabled={
!!formik.values.editRestriction ||
isDefaultProject ||
isNonEmpty
}
/>
<CheckboxInput
id="features_profiles"
Expand All @@ -255,7 +263,11 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
}
}}
checked={formik.values.features_profiles}
disabled={isDefaultProject || isNonEmpty}
disabled={
!!formik.values.editRestriction ||
isDefaultProject ||
isNonEmpty
}
/>
<CheckboxInput
id="features_networks"
Expand All @@ -269,7 +281,11 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
);
}}
checked={formik.values.features_networks}
disabled={isDefaultProject || isNonEmpty}
disabled={
!!formik.values.editRestriction ||
isDefaultProject ||
isNonEmpty
}
/>
{hasProjectsNetworksZones && (
<CheckboxInput
Expand All @@ -285,6 +301,7 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
}}
checked={formik.values.features_networks_zones}
disabled={
!!formik.values.editRestriction ||
isDefaultProject ||
(isNonEmpty && hadFeaturesNetworkZones)
}
Expand All @@ -303,7 +320,11 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
);
}}
checked={formik.values.features_storage_buckets}
disabled={isDefaultProject || isNonEmpty}
disabled={
!!formik.values.editRestriction ||
isDefaultProject ||
isNonEmpty
}
/>
)}
<CheckboxInput
Expand All @@ -318,40 +339,47 @@ const ProjectDetailsForm: FC<Props> = ({ formik, project, isEdit }) => {
);
}}
checked={formik.values.features_storage_volumes}
disabled={isDefaultProject || isNonEmpty}
disabled={
!!formik.values.editRestriction ||
isDefaultProject ||
isNonEmpty
}
/>
</>
)}
</div>

<hr />
<CheckboxInput
id="custom_restrictions"
name="custom_restrictions"
label={
<>
Allow custom restrictions on a project level
<Tooltip
className="checkbox-label-tooltip"
message={`Custom restrictions are only available${"\n"}to projects with enabled profiles`}
>
<Icon name="information" />
</Tooltip>
</>
}
onChange={() => {
ensureEditMode(formik);
void formik.setFieldValue(
"restricted",
!formik.values.restricted,
);
}}
checked={formik.values.restricted}
disabled={
formik.values.features_profiles === false &&
features === "customised"
}
/>
<div title={formik.values.editRestriction}>
<CheckboxInput
id="custom_restrictions"
name="custom_restrictions"
label={
<>
Allow custom restrictions on a project level
<Tooltip
className="checkbox-label-tooltip"
message={`Custom restrictions are only available${"\n"}to projects with enabled profiles`}
>
<Icon name="information" />
</Tooltip>
</>
}
onChange={() => {
ensureEditMode(formik);
void formik.setFieldValue(
"restricted",
!formik.values.restricted,
);
}}
checked={formik.values.restricted}
disabled={
!!formik.values.editRestriction ||
(formik.values.features_profiles === false &&
features === "customised")
}
/>
</div>
</Col>
</Row>
</ScrollableForm>
Expand Down
8 changes: 8 additions & 0 deletions src/util/entitlements/projects.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,20 @@ export const useProjectEntitlements = () => {
project?.access_entitlements,
);

const canDeleteProject = (project?: LxdProject) =>
hasEntitlement(isFineGrained, "can_delete", project?.access_entitlements);

const canEditProject = (project?: LxdProject) =>
hasEntitlement(isFineGrained, "can_edit", project?.access_entitlements);

return {
canCreateImageAliases,
canCreateImages,
canCreateInstances,
canCreateNetworks,
canCreateProfiles,
canCreateStorageVolumes,
canDeleteProject,
canEditProject,
};
};
24 changes: 15 additions & 9 deletions src/util/entitlements/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ import { hasEntitlement } from "./helpers";
export const useServerEntitlements = () => {
const { isFineGrained, serverEntitlements } = useAuth();

const canCreateProjects = () =>
hasEntitlement(isFineGrained, "can_create_projects", serverEntitlements) ||
hasEntitlement(isFineGrained, "project_manager", serverEntitlements) ||
hasEntitlement(isFineGrained, "admin", serverEntitlements);

const canCreateStoragePools = () =>
hasEntitlement(
isFineGrained,
"can_create_storage_pools",
serverEntitlements,
) ||
hasEntitlement(isFineGrained, "admin", serverEntitlements) ||
hasEntitlement(isFineGrained, "storage_pool_manager", serverEntitlements);

const canEditServerConfiguration = () =>
hasEntitlement(isFineGrained, "can_edit", serverEntitlements) ||
hasEntitlement(isFineGrained, "admin", serverEntitlements);
Expand All @@ -18,16 +32,8 @@ export const useServerEntitlements = () => {
hasEntitlement(isFineGrained, "admin", serverEntitlements) ||
hasEntitlement(isFineGrained, "viewer", serverEntitlements);

const canCreateStoragePools = () =>
hasEntitlement(
isFineGrained,
"can_create_storage_pools",
serverEntitlements,
) ||
hasEntitlement(isFineGrained, "admin", serverEntitlements) ||
hasEntitlement(isFineGrained, "storage_pool_manager", serverEntitlements);

return {
canCreateProjects,
canCreateStoragePools,
canEditServerConfiguration,
canViewMetrics,
Expand Down
2 changes: 2 additions & 0 deletions src/util/projectEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { LxdProfile } from "types/profile";
export const getProjectEditValues = (
project: LxdProject,
defaultProfile?: LxdProfile,
editRestriction?: string,
): ProjectFormValues => {
return {
name: project.name,
Expand Down Expand Up @@ -98,6 +99,7 @@ export const getProjectEditValues = (
restricted_network_subnets: project.config["restricted.networks.subnets"],
restricted_network_uplinks: project.config["restricted.networks.uplinks"],
restricted_network_zones: project.config["restricted.networks.zones"],
editRestriction,
};
};

Expand Down

0 comments on commit 56d5d58

Please sign in to comment.