feat(rbac): made changes from testing with maidul

This commit is contained in:
Akhil Mohan
2023-09-08 21:22:36 +05:30
parent 252042fb20
commit 98893a40f1
19 changed files with 612 additions and 529 deletions
@@ -191,7 +191,15 @@ export const createWorkspace = async (req: Request, res: Response) => {
* @returns * @returns
*/ */
export const deleteWorkspace = async (req: Request, res: Response) => { export const deleteWorkspace = async (req: Request, res: Response) => {
const { workspaceId } = req.params; const {
params: { workspaceId }
} = await validateRequest(reqValidator.DeleteWorkspaceV1, req);
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.Workspace
);
// delete workspace // delete workspace
await deleteWork({ await deleteWork({
@@ -246,7 +254,14 @@ export const changeWorkspaceName = async (req: Request, res: Response) => {
* @returns * @returns
*/ */
export const getWorkspaceIntegrations = async (req: Request, res: Response) => { export const getWorkspaceIntegrations = async (req: Request, res: Response) => {
const { workspaceId } = req.params; const {
params: { workspaceId }
} = await validateRequest(reqValidator.GetWorkspaceIntegrationsV1, req);
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.Integrations
);
const integrations = await Integration.find({ const integrations = await Integration.find({
workspace: workspaceId workspace: workspaceId
+8
View File
@@ -44,6 +44,14 @@ router.post(
workspaceController.createWorkspace workspaceController.createWorkspace
); );
router.delete(
"/:workspaceId",
requireAuth({
acceptedAuthModes: [AuthMode.JWT]
}),
workspaceController.deleteWorkspace
);
router.post( router.post(
"/:workspaceId/name", "/:workspaceId/name",
requireAuth({ requireAuth({
@@ -7,12 +7,18 @@ import { Tooltip } from "../v2";
type Props = { type Props = {
label?: ReactNode; label?: ReactNode;
// this prop is used when there exist already a tooltip as helper text for users
// so when permission is allowed same tooltip will be reused to show helpertext
renderTooltip?: boolean;
allowedLabel?: string;
} & BoundCanProps<TOrgPermission>; } & BoundCanProps<TOrgPermission>;
export const OrgPermissionCan: FunctionComponent<Props> = ({ export const OrgPermissionCan: FunctionComponent<Props> = ({
label = "Permission Denied. Kindly contact your org admin", label = "Permission Denied. Kindly contact your org admin",
children, children,
passThrough = true, passThrough = true,
renderTooltip,
allowedLabel,
...props ...props
}) => { }) => {
const permission = useOrgPermission(); const permission = useOrgPermission();
@@ -30,6 +36,10 @@ export const OrgPermissionCan: FunctionComponent<Props> = ({
return <Tooltip content={label}>{finalChild}</Tooltip>; return <Tooltip content={label}>{finalChild}</Tooltip>;
} }
if (isAllowed && renderTooltip) {
return <Tooltip content={allowedLabel}>{finalChild}</Tooltip>;
}
if (!isAllowed) return null; if (!isAllowed) return null;
return finalChild; return finalChild;
@@ -7,12 +7,18 @@ import { Tooltip } from "../v2";
type Props = { type Props = {
label?: ReactNode; label?: ReactNode;
// this prop is used when there exist already a tooltip as helper text for users
// so when permission is allowed same tooltip will be reused to show helpertext
renderTooltip?: boolean;
allowedLabel?: string;
} & BoundCanProps<TProjectPermission>; } & BoundCanProps<TProjectPermission>;
export const ProjectPermissionCan: FunctionComponent<Props> = ({ export const ProjectPermissionCan: FunctionComponent<Props> = ({
label = "Permission Denied. Kindly contact your project admin", label = "Permission Denied. Kindly contact your project admin",
children, children,
passThrough = true, passThrough = true,
renderTooltip,
allowedLabel,
...props ...props
}) => { }) => {
const permission = useProjectPermission(); const permission = useProjectPermission();
@@ -30,6 +36,10 @@ export const ProjectPermissionCan: FunctionComponent<Props> = ({
return <Tooltip content={label}>{finalChild}</Tooltip>; return <Tooltip content={label}>{finalChild}</Tooltip>;
} }
if (isAllowed && renderTooltip) {
return <Tooltip content={allowedLabel}>{finalChild}</Tooltip>;
}
if (!isAllowed) return null; if (!isAllowed) return null;
return finalChild; return finalChild;
@@ -45,7 +45,7 @@ export const Checkbox = ({
<FontAwesomeIcon icon={faCheck} size="sm" /> <FontAwesomeIcon icon={faCheck} size="sm" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
<label className="text-sm" htmlFor={id}> <label className="text-sm whitespace-nowrap" htmlFor={id}>
{children} {children}
{isRequired && <span className="pl-1 text-red">*</span>} {isRequired && <span className="pl-1 text-red">*</span>}
</label> </label>
+6 -30
View File
@@ -15,40 +15,16 @@ export type TRole<T extends string | undefined> = {
updatedAt: string; updatedAt: string;
}; };
export type TPermission = TWorkspacePermission | TGeneralPermission; export type TPermission = {
type TGeneralPermission = {
conditions?: Record<string, any>; conditions?: Record<string, any>;
action: "read" | "edit" | "create" | "delete"; action: string;
subject: "member" | "role" | "incident-contact" | "sso" | "billing" | "settings"; subject: string;
}; };
type TWorkspacePermission = { export type TProjectPermission = {
conditions?: Record<string, any>; conditions?: Record<string, any>;
action: "read" | "create"; action: string;
subject: "workspace"; subject: string;
};
export type TProjectPermission = TProjectGeneralPermission | TProjectWorkspacePermission;
type TProjectGeneralPermission = {
conditions?: Record<string, any>;
action: "read" | "edit" | "create" | "delete";
subject:
| "member"
| "role"
| "settings"
| "secrets"
| "environments"
| "folders"
| "secret-imports"
| "service-tokens";
};
type TProjectWorkspacePermission = {
conditions?: Record<string, any>;
action: "delete" | "edit";
subject: "workspace";
}; };
export type TCreateRoleDTO<T extends string | undefined> = { export type TCreateRoleDTO<T extends string | undefined> = {
+17 -1
View File
@@ -31,6 +31,7 @@ import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import * as yup from "yup"; import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import onboardingCheck from "@app/components/utilities/checks/OnboardingCheck"; import onboardingCheck from "@app/components/utilities/checks/OnboardingCheck";
import { tempLocalStorage } from "@app/components/utilities/checks/tempLocalStorage"; import { tempLocalStorage } from "@app/components/utilities/checks/tempLocalStorage";
import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto";
@@ -50,7 +51,14 @@ import {
SelectItem, SelectItem,
UpgradePlanModal UpgradePlanModal
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization, useSubscription, useUser, useWorkspace } from "@app/context"; import {
OrgPermissionActions,
OrgPermissionSubjects,
useOrganization,
useSubscription,
useUser,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { import {
fetchOrgUsers, fetchOrgUsers,
@@ -395,11 +403,17 @@ export const AppLayout = ({ children }: LayoutProps) => {
</div> </div>
<hr className="mt-1 mb-1 h-px border-0 bg-gray-700" /> <hr className="mt-1 mb-1 h-px border-0 bg-gray-700" />
<div className="w-full"> <div className="w-full">
<OrgPermissionCan
I={OrgPermissionActions.Create}
a={OrgPermissionSubjects.Workspace}
>
{(isAllowed) => (
<Button <Button
className="w-full bg-mineshaft-700 py-2 text-bunker-200" className="w-full bg-mineshaft-700 py-2 text-bunker-200"
colorSchema="primary" colorSchema="primary"
variant="outline_bg" variant="outline_bg"
size="sm" size="sm"
isDisabled={!isAllowed}
onClick={() => { onClick={() => {
if (isAddingProjectsAllowed) { if (isAddingProjectsAllowed) {
handlePopUpOpen("addNewWs"); handlePopUpOpen("addNewWs");
@@ -411,6 +425,8 @@ export const AppLayout = ({ children }: LayoutProps) => {
> >
Add Project Add Project
</Button> </Button>
)}
</OrgPermissionCan>
</div> </div>
</Select> </Select>
</div> </div>
@@ -20,7 +20,7 @@ const SettingsBilling = withPermission<{}, TOrgPermission>(
</div> </div>
); );
}, },
{ action: OrgPermissionActions.Delete, subject: OrgPermissionSubjects.Billing } { action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Billing }
); );
Object.assign(SettingsBilling, { requireAuth: true }); Object.assign(SettingsBilling, { requireAuth: true });
+11 -1
View File
@@ -1,6 +1,10 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
html {
@apply overflow-hidden;
}
.rdp-day, .rdp-day,
.rdp-nav_button { .rdp-nav_button {
@apply rounded-md hover:text-mineshaft-500; @apply rounded-md hover:text-mineshaft-500;
@@ -108,7 +112,13 @@
} }
} }
.tags-conic-bg { .tags-conic-bg {
background: conic-gradient(rgb(235, 87, 87), rgb(242, 201, 76), rgb(76, 183, 130), rgb(78, 167, 252), rgb(250, 96, 122)); background: conic-gradient(
rgb(235, 87, 87),
rgb(242, 201, 76),
rgb(76, 183, 130),
rgb(78, 167, 252),
rgb(250, 96, 122)
);
} }
.show-tags { .show-tags {
@@ -1138,7 +1138,6 @@ export const DashboardPage = withProjectPermission(
onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)}
/> />
</FormProvider> </FormProvider>
<SecretDropzone <SecretDropzone
workspaceId={workspaceId} workspaceId={workspaceId}
isSmaller={!isEmptyPage} isSmaller={!isEmptyPage}
@@ -15,6 +15,7 @@ import { twMerge } from "tailwind-merge";
import * as yup from "yup"; import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions";
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality // TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
import { parseDotEnv } from "@app/components/utilities/parseDotEnv"; import { parseDotEnv } from "@app/components/utilities/parseDotEnv";
import { import {
@@ -33,7 +34,6 @@ import {
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDebounce, usePopUp, useToggle } from "@app/hooks"; import { useDebounce, usePopUp, useToggle } from "@app/hooks";
import { useGetProjectSecrets } from "@app/hooks/api"; import { useGetProjectSecrets } from "@app/hooks/api";
import { UserWsKeyPair } from "@app/hooks/api/types"; import { UserWsKeyPair } from "@app/hooks/api/types";
@@ -80,8 +80,7 @@ type Props = {
decryptFileKey: UserWsKeyPair; decryptFileKey: UserWsKeyPair;
}; };
export const SecretDropzone = withProjectPermission( export const SecretDropzone = ({
({
isSmaller, isSmaller,
onParsedEnv, onParsedEnv,
onAddNewSecret, onAddNewSecret,
@@ -226,12 +225,7 @@ export const SecretDropzone = withProjectPermission(
> >
{isLoading ? ( {isLoading ? (
<div className="mb-16 flex items-center justify-center pt-16"> <div className="mb-16 flex items-center justify-center pt-16">
<img <img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
src="/images/loading/loading.gif"
height={70}
width={120}
alt="loading animation"
/>
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
@@ -242,13 +236,21 @@ export const SecretDropzone = withProjectPermission(
<div> <div>
<p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p> <p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p>
</div> </div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Secrets}
>
{(isAllowed) => (
<input <input
id="fileSelect" id="fileSelect"
disabled={!isAllowed}
type="file" type="file"
className="absolute h-full w-full cursor-pointer opacity-0" className="absolute h-full w-full cursor-pointer opacity-0"
accept=".txt,.env,.yml,.yaml,.json" accept=".txt,.env,.yml,.yaml,.json"
onChange={handleFileUpload} onChange={handleFileUpload}
/> />
)}
</ProjectPermissionCan>
<div <div
className={twMerge( className={twMerge(
"flex w-full flex-row items-center justify-center py-4", "flex w-full flex-row items-center justify-center py-4",
@@ -269,9 +271,16 @@ export const SecretDropzone = withProjectPermission(
}} }}
> >
<ModalTrigger asChild> <ModalTrigger asChild>
<Button variant="star" size={isSmaller ? "xs" : "sm"}> <ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Secrets}
>
{(isAllowed) => (
<Button isDisabled={!isAllowed} variant="star" size={isSmaller ? "xs" : "sm"}>
Copy Secrets From An Environment Copy Secrets From An Environment
</Button> </Button>
)}
</ProjectPermissionCan>
</ModalTrigger> </ModalTrigger>
<ModalContent <ModalContent
className="max-w-2xl" className="max-w-2xl"
@@ -369,9 +378,7 @@ export const SecretDropzone = withProjectPermission(
<Checkbox <Checkbox
id={`pull-secret-${_id}`} id={`pull-secret-${_id}`}
isChecked={Boolean(value)} isChecked={Boolean(value)}
onCheckedChange={(isChecked) => onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
onChange(isChecked ? secVal : "")
}
> >
{key} {key}
</Checkbox> </Checkbox>
@@ -407,16 +414,21 @@ export const SecretDropzone = withProjectPermission(
</ModalContent> </ModalContent>
</Modal> </Modal>
{!isSmaller && ( {!isSmaller && (
<Button variant="star" onClick={onAddNewSecret}> <ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Secrets}
>
{(isAllowed) => (
<Button variant="star" onClick={onAddNewSecret} isDisabled={!isAllowed}>
Add a new secret Add a new secret
</Button> </Button>
)} )}
</ProjectPermissionCan>
)}
</div> </div>
</div> </div>
</form> </form>
)} )}
</div> </div>
); );
}, };
{ action: ProjectPermissionActions.Create, subject: ProjectPermissionSub.Secrets }
);
@@ -16,7 +16,8 @@ export const formSchema = z.object({
name: z.string().trim(), name: z.string().trim(),
description: z.string().trim().optional(), description: z.string().trim().optional(),
slug: z.string().trim(), slug: z.string().trim(),
permissions: z.object({ permissions: z
.object({
workspace: z workspace: z
.object({ .object({
read: z.boolean().optional(), read: z.boolean().optional(),
@@ -32,21 +33,20 @@ export const formSchema = z.object({
sso: generalPermissionSchema, sso: generalPermissionSchema,
billing: generalPermissionSchema billing: generalPermissionSchema
}) })
.optional()
}); });
export type TFormSchema = z.infer<typeof formSchema>; export type TFormSchema = z.infer<typeof formSchema>;
// convert role permission to form compatiable data structure // convert role permission to form compatiable data structure
export const rolePermission2Form = (permissions: TPermission[] = []) => { export const rolePermission2Form = (permissions: TPermission[] = []) => {
const formVal: Partial<TFormSchema["permissions"]> = {}; // any because if it set it as form type due to the discriminated union type of ts
// i would have to write a if loop with both conditions same
const formVal: Record<string, any> = {};
permissions.forEach((permission) => { permissions.forEach((permission) => {
const { subject, action } = permission; const { subject, action } = permission;
if (!formVal?.[subject]) formVal[subject] = {}; if (!formVal?.[subject]) formVal[subject] = {};
formVal[subject][action] = true;
// akhilmhdh: this is typecast as something other than workspace key else i would need an if loop with same condition on both side
const key = subject as keyof TFormSchema["permissions"];
(formVal[key] as Exclude<TFormSchema["permissions"]["member"], undefined>)[action] = true;
}); });
return formVal; return formVal;
@@ -54,19 +54,13 @@ export const rolePermission2Form = (permissions: TPermission[] = []) => {
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
const permissions: TPermission[] = []; const permissions: TPermission[] = [];
(Object.keys(formVal) as Array<keyof typeof formVal>).forEach((rule) => { Object.entries(formVal || {}).forEach(([rule, actions]) => {
// all these type annotations are due to Object.keys of ts cannot infer and put it just a string[] Object.entries(actions).forEach(([action, isAllowed]) => {
// quite annoying i know if (isAllowed) {
const actions = Object.keys(formVal[rule] || {}) as Array< permissions.push({ subject: rule, action });
keyof z.infer<typeof generalPermissionSchema>
>;
actions.forEach((action) => {
// akhilmhdh: set it as any due to the union type bug i would end up writing an if else with same condition on both side
if (formVal?.[rule]?.[action as keyof typeof formVal.workspace]) {
permissions.push({ subject: rule, action } as any);
} }
}); });
}); });
return permissions; return permissions;
}; };
@@ -11,7 +11,7 @@ import { useToggle } from "@app/hooks";
import { TFormSchema } from "./OrgRoleModifySection.utils"; import { TFormSchema } from "./OrgRoleModifySection.utils";
type Props = { type Props = {
formName: keyof Omit<TFormSchema["permissions"], "workspace">; formName: keyof Omit<Exclude<TFormSchema["permissions"], undefined>, "workspace">;
isNonEditable?: boolean; isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>; setValue: UseFormSetValue<TFormSchema>;
control: Control<TFormSchema>; control: Control<TFormSchema>;
@@ -34,6 +34,31 @@ const PERMISSIONS = [
{ action: "delete", label: "Remove" } { action: "delete", label: "Remove" }
] as const; ] as const;
const SECRET_SCANNING_PERMISSIONS = [
{ action: "read", label: "View risks" },
{ action: "create", label: "Add integrations" },
{ action: "edit", label: "Edit risk status" },
{ action: "delete", label: "Remove integrations" }
] as const;
const BILLING_PERMISSIONS = [
{ action: "read", label: "View bills" },
{ action: "create", label: "Add payment methods" },
{ action: "edit", label: "Edit payments" },
{ action: "delete", label: "Remove payments" }
] as const;
const getPermissionList = (option: Props["formName"]) => {
switch (option) {
case "secret-scanning":
return SECRET_SCANNING_PERMISSIONS;
case "billing":
return BILLING_PERMISSIONS;
default:
return PERMISSIONS;
}
};
export const SimpleLevelPermissionOption = ({ export const SimpleLevelPermissionOption = ({
isNonEditable, isNonEditable,
setValue, setValue,
@@ -138,7 +163,7 @@ export const SimpleLevelPermissionOption = ({
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min" className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
> >
{isCustom && {isCustom &&
PERMISSIONS.map(({ action, label }) => ( getPermissionList(formName).map(({ action, label }) => (
<Controller <Controller
name={`permissions.${formName}.${action}`} name={`permissions.${formName}.${action}`}
key={`permissions.${formName}.${action}`} key={`permissions.${formName}.${action}`}
@@ -86,7 +86,9 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props)
</div> </div>
<div className="flex-grow flex flex-col"> <div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">Project</div> <div className="font-medium mb-1 text-lg">Project</div>
<div className="text-xs font-light">Project management control</div> <div className="text-xs font-light">
More fine granined project access control can be defined with project level roles
</div>
</div> </div>
<div> <div>
<Select <Select
@@ -4,6 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { OrgPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DeleteActionModal, DeleteActionModal,
@@ -16,10 +17,9 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useOrganization } from "@app/context"; import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteRole } from "@app/hooks/api"; import { useDeleteRole } from "@app/hooks/api";
import { TRole } from "@app/hooks/api/roles/types"; import { TRole } from "@app/hooks/api/roles/types";
@@ -65,9 +65,17 @@ export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props
placeholder="Search roles..." placeholder="Search roles..."
/> />
</div> </div>
<Button leftIcon={<FontAwesomeIcon icon={faPlus} />} onClick={() => onSelectRole()}> <OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Role}>
{(isAllowed) => (
<Button
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => onSelectRole()}
>
Add Role Add Role
</Button> </Button>
)}
</OrgPermissionCan>
</div> </div>
<div> <div>
<TableContainer> <TableContainer>
@@ -95,27 +103,42 @@ export const OrgRoleTable = ({ isRolesLoading, roles = [], onSelectRole }: Props
</Td> </Td>
<Td> <Td>
<div className="flex space-x-2 items-center"> <div className="flex space-x-2 items-center">
<Tooltip content="Edit"> <OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Role}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton <IconButton
isDisabled={!isAllowed}
ariaLabel="edit" ariaLabel="edit"
onClick={() => onSelectRole(role)} onClick={() => onSelectRole(role)}
variant="plain" variant="plain"
> >
<FontAwesomeIcon icon={faEdit} /> <FontAwesomeIcon icon={faEdit} />
</IconButton> </IconButton>
</Tooltip> )}
<Tooltip </OrgPermissionCan>
content={isNonMutatable ? "Reserved roles are non-removable" : "Delete"} <OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Role}
renderTooltip
allowedLabel={
isNonMutatable ? "Reserved roles are non-removable" : "Delete"
}
> >
{(isAllowed) => (
<IconButton <IconButton
ariaLabel="delete" ariaLabel="delete"
onClick={() => handlePopUpOpen("deleteRole", role)} onClick={() => handlePopUpOpen("deleteRole", role)}
variant="plain" variant="plain"
isDisabled={isNonMutatable} isDisabled={isNonMutatable || !isAllowed}
> >
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
</Tooltip> )}
</OrgPermissionCan>
</div> </div>
</Td> </Td>
</Tr> </Tr>
@@ -17,7 +17,6 @@ import {
Td, Td,
Th, Th,
THead, THead,
Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
@@ -116,10 +115,10 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Role} a={ProjectPermissionSub.Role}
renderTooltip
allowedLabel="Edit"
> >
{(isAllowed) => ( {(isAllowed) => (
<div>
<Tooltip content="Edit">
<IconButton <IconButton
isDisabled={!isAllowed} isDisabled={!isAllowed}
ariaLabel="edit" ariaLabel="edit"
@@ -128,21 +127,17 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
> >
<FontAwesomeIcon icon={faEdit} /> <FontAwesomeIcon icon={faEdit} />
</IconButton> </IconButton>
</Tooltip>
</div>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Role} a={ProjectPermissionSub.Role}
> renderTooltip
{(isAllowed) => ( allowedLabel={
<div>
<Tooltip
content={
isNonMutatable ? "Reserved roles are non-removable" : "Delete" isNonMutatable ? "Reserved roles are non-removable" : "Delete"
} }
> >
{(isAllowed) => (
<IconButton <IconButton
ariaLabel="delete" ariaLabel="delete"
onClick={() => handlePopUpOpen("deleteRole", role)} onClick={() => handlePopUpOpen("deleteRole", role)}
@@ -151,8 +146,6 @@ export const ProjectRoleList = ({ isRolesLoading, roles = [], onSelectRole }: Pr
> >
<FontAwesomeIcon icon={faTrash} /> <FontAwesomeIcon icon={faTrash} />
</IconButton> </IconButton>
</Tooltip>
</div>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
@@ -29,7 +29,8 @@ export const formSchema = z.object({
name: z.string().trim(), name: z.string().trim(),
description: z.string().trim().optional(), description: z.string().trim().optional(),
slug: z.string().trim(), slug: z.string().trim(),
permissions: z.object({ permissions: z
.object({
secrets: z.record(multiEnvPermissionSchema).optional(), secrets: z.record(multiEnvPermissionSchema).optional(),
folders: z.record(multiEnvPermissionSchema).optional(), folders: z.record(multiEnvPermissionSchema).optional(),
"secret-imports": z.record(multiEnvPermissionSchema).optional(), "secret-imports": z.record(multiEnvPermissionSchema).optional(),
@@ -56,12 +57,13 @@ export const formSchema = z.object({
}) })
.optional() .optional()
}) })
.optional()
}); });
export type TFormSchema = z.infer<typeof formSchema>; export type TFormSchema = z.infer<typeof formSchema>;
const multiEnvApi2Form = ( const multiEnvApi2Form = (
formVal: TFormSchema["permissions"]["secrets"], formVal: Record<string, { secretPath?: string } & { [key: string]: boolean }>,
permission: TProjectPermission permission: TProjectPermission
) => { ) => {
const isCustomRule = Boolean(permission?.conditions?.environment); const isCustomRule = Boolean(permission?.conditions?.environment);
@@ -76,28 +78,26 @@ const multiEnvApi2Form = (
if (formVal && !formVal?.[secretEnv]) { if (formVal && !formVal?.[secretEnv]) {
formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath }; formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath };
} }
formVal![secretEnv]![permission.action] = true;
formVal[secretEnv][permission.action] = true;
}; };
// convert role permission to form compatiable data structure // convert role permission to form compatiable data structure
export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const formVal: Partial<TFormSchema["permissions"]> = {}; // any because if it set it as form type due to the discriminated union type of ts
// i would have to write a if loop with both conditions same
const formVal: Record<string, any> = {};
permissions.forEach((permission) => { permissions.forEach((permission) => {
const { subject, action } = permission; const { subject, action } = permission;
if (!formVal?.[subject]) formVal[subject] = {}; if (!formVal?.[subject]) formVal[subject] = {};
if (["secrets", "folders", "secret-imports"].includes(subject)) { if (["secrets", "folders", "secret-imports"].includes(subject)) {
multiEnvApi2Form(formVal[subject] as TFormSchema["permissions"]["secrets"], permission); multiEnvApi2Form(formVal[subject], permission);
} else { } else {
// everything else follows same pattern // everything else follows same pattern
// formVal[settings][read | write] = true // formVal[settings][read | write] = true
formVal[ formVal[subject][action] = true;
subject as keyof Omit<
TFormSchema["permissions"],
"secrets" | "workspace" | "secret-rollback"
>
]![action] = true;
} }
}); });
@@ -106,7 +106,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const multiEnvForm2Api = ( const multiEnvForm2Api = (
permissions: TProjectPermission[], permissions: TProjectPermission[],
formVal: TFormSchema["permissions"]["secrets"], formVal: Record<string, { secretPath?: string } & { [key: string]: boolean }>,
subject: (typeof MULTI_ENV_KEY)[number] subject: (typeof MULTI_ENV_KEY)[number]
) => { ) => {
const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]);
@@ -142,22 +142,14 @@ const multiEnvForm2Api = (
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
const permissions: TProjectPermission[] = []; const permissions: TProjectPermission[] = [];
MULTI_ENV_KEY.forEach((formName) => { MULTI_ENV_KEY.forEach((formName) => {
multiEnvForm2Api(permissions, JSON.parse(JSON.stringify(formVal[formName] || {})), formName); multiEnvForm2Api(permissions, JSON.parse(JSON.stringify(formVal?.[formName] || {})), formName);
}); });
// other than workspace everything else follows same // other than workspace everything else follows same
// if in future there is a different follow the above on how workspace is done // if in future there is a different follow the above on how workspace is done
(Object.keys(formVal) as Array<keyof typeof formVal>) Object.entries(formVal || {}).forEach(([rule, actions]) => {
.filter((key) => !["secret-imports", "folders", "secrets"].includes(key)) Object.entries(actions).forEach(([action, isAllowed]) => {
.forEach((rule) => { if (isAllowed) {
// all these type annotations are due to Object.keys of ts cannot infer and put it just a string[] permissions.push({ subject: rule, action });
// quite annoying i know
const actions = Object.keys(formVal[rule] || {}) as Array<
keyof z.infer<typeof generalPermissionSchema>
>;
actions.forEach((action) => {
// akhilmhdh: set it as any due to the union type bug i would end up writing an if else with same condition on both side
if (formVal[rule]?.[action as keyof typeof formVal.workspace]) {
permissions.push({ subject: rule, action } as any);
} }
}); });
}); });
@@ -23,8 +23,8 @@ enum Permission {
} }
const PERMISSIONS = [ const PERMISSIONS = [
{ action: "edit", label: "Update" }, { action: "edit", label: "Update workspace details" },
{ action: "delete", label: "Remove" } { action: "delete", label: "Delete workspace" }
] as const; ] as const;
export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) => { export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) => {
@@ -80,8 +80,8 @@ export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props)
<FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" /> <FontAwesomeIcon icon={faPuzzlePiece} className="text-4xl" />
</div> </div>
<div className="flex-grow flex flex-col"> <div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">Workspace</div> <div className="font-medium mb-1 text-lg">Project</div>
<div className="text-xs font-light">Workspace control actions</div> <div className="text-xs font-light">Project control actions</div>
</div> </div>
<div> <div>
<Select <Select
@@ -1,12 +1,10 @@
import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection"; import { OrgIncidentContactsSection } from "../OrgIncidentContactsSection";
import { OrgNameChangeSection } from "../OrgNameChangeSection"; import { OrgNameChangeSection } from "../OrgNameChangeSection";
import { OrgServiceAccountsTable } from "../OrgServiceAccountsTable";
export const OrgGeneralTab = () => { export const OrgGeneralTab = () => {
return ( return (
<div> <div>
<OrgNameChangeSection /> <OrgNameChangeSection />
<OrgServiceAccountsTable />
<OrgIncidentContactsSection /> <OrgIncidentContactsSection />
</div> </div>
); );