Fix: Added support for request access

This commit is contained in:
Daniel Hougaard
2024-05-07 17:30:35 +02:00
parent 7aee4fdfcd
commit d380b7f788
2 changed files with 235 additions and 36 deletions
+1
View File
@@ -4,3 +4,4 @@ frontend/src/views/Project/MembersPage/components/IdentityTab/components/Identit
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx:generic-api-key:206
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292 frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:292
docs/self-hosting/configuration/envars.mdx:generic-api-key:106 docs/self-hosting/configuration/envars.mdx:generic-api-key:106
frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx:generic-api-key:451
@@ -1,9 +1,11 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { import {
faArrowRotateLeft, faArrowRotateLeft,
faCaretDown, faCaretDown,
faCheck, faCheck,
faClock, faClock,
faLockOpen,
faPlus, faPlus,
faTrash faTrash
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -44,11 +46,13 @@ import {
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
TProjectUserPrivilege, TProjectUserPrivilege,
useCreateAccessRequest,
useCreateProjectUserAdditionalPrivilege, useCreateProjectUserAdditionalPrivilege,
useDeleteProjectUserAdditionalPrivilege, useDeleteProjectUserAdditionalPrivilege,
useListProjectUserPrivileges, useListProjectUserPrivileges,
useUpdateProjectUserAdditionalPrivilege useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api"; } from "@app/hooks/api";
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
const secretPermissionSchema = z.object({ const secretPermissionSchema = z.object({
secretPath: z.string().optional(), secretPath: z.string().optional(),
@@ -70,51 +74,107 @@ const secretPermissionSchema = z.object({
]) ])
}); });
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>; type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => { export const SpecificPrivilegeSecretForm = ({
privilege,
policies,
onClose
}: {
privilege?: TProjectUserPrivilege;
policies?: TAccessApprovalPolicy[];
onClose?: () => void;
}) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { membership: projectMembership } = useProjectPermission();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deletePrivilege" "deletePrivilege",
"requestAccess"
] as const); ] as const);
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot( const isMemberEditDisabled =
ProjectPermissionActions.Edit, permission.cannot(ProjectPermissionActions.Edit, ProjectPermissionSub.Member) && !!privilege;
ProjectPermissionSub.Member
);
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege(); const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege(); const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
const requestAccess = useCreateAccessRequest();
const privilegeForm = useForm<TSecretPermissionForm>({ const privilegeForm = useForm<TSecretPermissionForm>({
resolver: zodResolver(secretPermissionSchema), resolver: zodResolver(secretPermissionSchema),
values: { values: {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment, ...(privilege
// secret path will be inside $glob operator ? {
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
read: privilege.permissions?.some(({ action }) => // secret path will be inside $glob operator
action.includes(ProjectPermissionActions.Read) secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
), read: privilege.permissions?.some(({ action }) =>
edit: privilege.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read)
action.includes(ProjectPermissionActions.Edit) ),
), edit: privilege.permissions?.some(({ action }) =>
create: privilege.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Edit)
action.includes(ProjectPermissionActions.Create) ),
), create: privilege.permissions?.some(({ action }) =>
delete: privilege.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Create)
action.includes(ProjectPermissionActions.Delete) ),
), delete: privilege.permissions?.some(({ action }) =>
// zod will pick it action.includes(ProjectPermissionActions.Delete)
temporaryAccess: privilege ),
// zod will pick it
temporaryAccess: privilege
}
: {
environmentSlug: currentWorkspace?.environments?.[0].slug!,
read: false,
edit: false,
create: false,
delete: false,
temporaryAccess: {
isTemporary: false
}
})
} }
}); });
const temporaryAccessField = privilegeForm.watch("temporaryAccess"); const temporaryAccessField = privilegeForm.watch("temporaryAccess");
const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug"); const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug");
const selectedEnvironment = privilegeForm.watch("environmentSlug");
const secretPath = privilegeForm.watch("secretPath");
const readAccess = privilegeForm.watch("read");
const createAccess = privilegeForm.watch("create");
const editAccess = privilegeForm.watch("edit");
const deleteAccess = privilegeForm.watch("delete");
const accessSelected = readAccess || createAccess || editAccess || deleteAccess;
const selectablePaths = useMemo(() => {
if (!policies) return [];
const environmentPolicies = policies.filter(
(policy) => policy.environment.slug === selectedEnvironment
);
privilegeForm.setValue("secretPath", "", {
shouldValidate: true
});
return [...environmentPolicies.map((policy) => policy.secretPath)];
}, [policies, selectedEnvironment]);
const isTemporary = temporaryAccessField?.isTemporary; const isTemporary = temporaryAccessField?.isTemporary;
const isExpired = const isExpired =
temporaryAccessField.isTemporary && temporaryAccessField.isTemporary &&
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || ""); new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => { const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
if (!privilege) {
createNotification({
type: "error",
text: "No privilege to update found.",
title: "Error"
});
return;
}
if (updateUserPrivilege.isLoading) return; if (updateUserPrivilege.isLoading) return;
try { try {
const actions = [ const actions = [
@@ -152,6 +212,15 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
}; };
const handleDeletePrivilege = async () => { const handleDeletePrivilege = async () => {
if (!privilege) {
createNotification({
type: "error",
text: "No privilege to delete found.",
title: "Error"
});
return;
}
if (deleteUserPrivilege.isLoading) return; if (deleteUserPrivilege.isLoading) return;
try { try {
await deleteUserPrivilege.mutateAsync({ await deleteUserPrivilege.mutateAsync({
@@ -170,35 +239,122 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
} }
}; };
const getAccessLabel = (exactTime = false) => { const getAccessLabel = (exactTime = false) => {
if (isExpired) return "Access expired"; if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent"; if (!temporaryAccessField?.isTemporary) return "Permanent";
if (exactTime) if (exactTime)
return `Until ${format( return `Until ${format(
new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date(temporaryAccessField.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss" "yyyy-MM-dd HH:mm:ss"
)}`; )}`;
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date()); return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
};
};
// This is used for requesting access additional privileges, not directly creating a privilege!
const handleRequestAccess = async (data: TSecretPermissionForm) => {
if (!policies) return;
if (!currentWorkspace) {
createNotification({
type: "error",
text: "No workspace found.",
title: "Error"
});
return;
}
if (!data.secretPath) {
createNotification({
type: "error",
text: "Please select a secret path",
title: "Error"
});
return;
}
const actions = [
{ action: ProjectPermissionActions.Read, allowed: data.read },
{ action: ProjectPermissionActions.Create, allowed: data.create },
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
];
const conditions: Record<string, any> = { environment: data.environmentSlug };
if (data.secretPath) {
conditions.secretPath = { $glob: data.secretPath };
}
await requestAccess.mutateAsync({
...data,
...(data.temporaryAccess.isTemporary && {
temporaryAccessStartTime: data.temporaryAccess.temporaryAccessStartTime,
temporaryAccessEndTime: data.temporaryAccess.temporaryAccessEndTime,
temporaryRange: data.temporaryAccess.temporaryRange,
temporaryMode: "relative"
}),
envSlug: data.environmentSlug,
secretPath: data.secretPath,
projectSlug: currentWorkspace.slug,
projectMembershipId: projectMembership.id,
isTemporary: data.temporaryAccess.isTemporary,
permissions: actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
}))
});
createNotification({
type: "success",
text: "Successfully requested access"
});
privilegeForm.reset();
if (onClose) onClose();
};
const handleSubmit = async (data: TSecretPermissionForm) => {
if (privilege) {
handleUpdatePrivilege(data);
} else {
handleRequestAccess(data);
}
};
const getAccessLabel = (exactTime = false) => {
if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent";
if (exactTime)
return `Until ${format(
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`;
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
};
}; };
return ( return (
<div className="mt-4"> <div className="mt-4 w-full">
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}> <form onSubmit={privilegeForm.handleSubmit(handleSubmit)}>
<div className="flex items-start space-x-4"> <div className={twMerge("flex items-start gap-4", !privilege && "flex-wrap")}>
<Controller <Controller
control={privilegeForm.control} control={privilegeForm.control}
name="environmentSlug" name="environmentSlug"
render={({ field: { onChange, ...field } }) => ( render={({ field: { onChange, ...field } }) => (
<FormControl label="Env"> <FormControl label="Environment">
<Select <Select
{...field} {...field}
isDisabled={isMemberEditDisabled} isDisabled={isMemberEditDisabled}
className="bg-mineshaft-600 hover:bg-mineshaft-500" className="bg-mineshaft-600 hover:bg-mineshaft-500"
onValueChange={(e) => onChange(e)} onValueChange={(e) => onChange(e)}
> >
{currentWorkspace?.environments?.map(({ slug, id }) => ( {currentWorkspace?.environments?.map(({ slug, id, name }) => (
<SelectItem value={slug} key={id}> <SelectItem value={slug} key={id}>
{slug} {name}
</SelectItem> </SelectItem>
))} ))}
</Select> </Select>
@@ -208,8 +364,28 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
<Controller <Controller
control={privilegeForm.control} control={privilegeForm.control}
name="secretPath" name="secretPath"
render={({ field }) => ( render={({ field }) => {
<FormControl label="Secret Path"> console.log(policies);
if (policies) {
return (
<FormControl label="Secret Path">
<Select
{...field}
isDisabled={isMemberEditDisabled || !selectablePaths.length}
className="w-48"
onValueChange={(e) => field.onChange(e)}
>
{selectablePaths.map((path) => (
<SelectItem value={path} key={path}>
{path}
</SelectItem>
))}
</Select>
</FormControl>
);
}
return (
<FormControl label="Secret Path">
<SecretPathInput <SecretPathInput
{...field} {...field}
isDisabled={isMemberEditDisabled} isDisabled={isMemberEditDisabled}
@@ -217,7 +393,8 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
environment={selectedEnvironmentSlug} environment={selectedEnvironmentSlug}
/> />
</FormControl> </FormControl>
)} );
}}
/> />
<div className="flex flex-grow justify-between"> <div className="flex flex-grow justify-between">
<Controller <Controller
@@ -285,7 +462,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
)} )}
/> />
</div> </div>
<div className="mt-7 flex items-center space-x-2"> <div className="mt-6 flex items-center space-x-2">
<Popover> <Popover>
<PopoverTrigger disabled={isMemberEditDisabled}> <PopoverTrigger disabled={isMemberEditDisabled}>
<div> <div>
@@ -301,7 +478,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
isExpired && "text-red-600" isExpired && "text-red-600"
)} )}
> >
{getAccessLabel()} {getAccessLabel(false)}
</Button> </Button>
</Tooltip> </Tooltip>
</div> </div>
@@ -382,7 +559,8 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{privilegeForm.formState.isDirty ? ( {/* eslint-disable-next-line no-nested-ternary */}
{privilegeForm.formState.isDirty && privilege ? (
<> <>
<Tooltip content="Cancel" className="mr-4"> <Tooltip content="Cancel" className="mr-4">
<IconButton <IconButton
@@ -413,7 +591,8 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</> </>
) : ( ) : // eslint-disable-next-line no-nested-ternary
privilege ? (
<Tooltip <Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Delete"} content={isMemberEditDisabled ? "Access restricted" : "Delete"}
className="mr-4" className="mr-4"
@@ -428,9 +607,28 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
) : (
<div />
)} )}
</div> </div>
</div> </div>
{!!policies && (
<Button
type="submit"
isLoading={privilegeForm.formState.isSubmitting || requestAccess.isLoading}
isDisabled={
isMemberEditDisabled ||
!policies.length ||
!privilegeForm.formState.isValid ||
!secretPath ||
!accessSelected
}
className="mt-4"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Request access
</Button>
)}
</form> </form>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen} isOpen={popUp.deletePrivilege.isOpen}