feat(rbac): added new zod validation and permission check for all org level api

This commit is contained in:
Akhil Mohan
2023-09-08 21:20:29 +05:30
parent 96437fd1b7
commit e342e88499
27 changed files with 1870 additions and 1593 deletions
+10 -2
View File
@@ -15,10 +15,18 @@ export type TRole = {
updatedAt: string;
};
export type TPermission = {
export type TPermission = TWorkspacePermission | TGeneralPermission;
type TGeneralPermission = {
condition?: Record<string, any>;
action: "read" | "edit" | "create" | "delete";
subject: string;
subject: "member" | "role" | "incident-contact" | "sso" | "billing" | "settings";
};
type TWorkspacePermission = {
condition?: Record<string, any>;
action: "read" | "create";
subject: "workspace";
};
export type TCreateRoleDTO = {
@@ -20,7 +20,7 @@ import {
TFormSchema
} from "./OrgRoleModifySection.utils";
import { RolePermission } from "./RolePermission";
import { ServiceAccountPermission } from "./ServiceAccountPermission";
import { SecretScannigPermission } from "./SecretScanningPermission";
import { SettingsPermission } from "./SettingsPermission";
import { SsoPermission } from "./SsoPermission";
import { WorkspacePermission } from "./WorkspacePermission";
@@ -183,15 +183,15 @@ export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {
/>
</div>
<div className="flex flex-col space-y-4">
<SsoPermission isNonEditable={isNonEditable} control={control} setValue={setValue} />
</div>
<div className="flex flex-col space-y-4">
<ServiceAccountPermission
<SecretScannigPermission
isNonEditable={isNonEditable}
control={control}
setValue={setValue}
/>
</div>
<div className="flex flex-col space-y-4">
<SsoPermission isNonEditable={isNonEditable} control={control} setValue={setValue} />
</div>
</div>
<div className="flex items-center space-x-4 mt-12">
<Button
@@ -3,8 +3,6 @@ import { z } from "zod";
import { TPermission } from "@app/hooks/api/roles/types";
const PERMISSION_ACTIONS = ["read", "create", "edit", "delete"] as const;
const generalPermissionSchema = z.object({
read: z.boolean().optional(),
edit: z.boolean().optional(),
@@ -17,12 +15,16 @@ export const formSchema = z.object({
description: z.string().optional(),
slug: z.string(),
permissions: z.object({
workspace: z.record(generalPermissionSchema),
workspace: z.object({
read: z.boolean().optional(),
create: z.boolean().optional()
}),
member: generalPermissionSchema,
role: generalPermissionSchema,
settings: generalPermissionSchema,
"service-account": generalPermissionSchema,
"incident-contact": generalPermissionSchema,
"secret-scanning": generalPermissionSchema,
sso: generalPermissionSchema,
billing: generalPermissionSchema
})
@@ -30,25 +32,6 @@ export const formSchema = z.object({
export type TFormSchema = z.infer<typeof formSchema>;
const api2FormWorkspace = (
formVal: TFormSchema["permissions"]["workspace"],
permission: TPermission
) => {
if (permission.subject !== "workspace") return;
const isCustomRule = Boolean(permission?.condition?.id);
// full access
if (isCustomRule && !formVal?.custom) {
formVal.custom = { read: true, edit: true, delete: true, create: true };
}
const workspaceId = permission?.condition?.id || "all";
// initalize
if (!formVal?.[workspaceId]) {
formVal[workspaceId] = { read: false, edit: false, create: false, delete: false };
}
formVal[workspaceId][permission.action] = true;
};
// convert role permission to form compatiable data structure
export const rolePermission2Form = (permissions: TPermission[] = []) => {
const formVal: TFormSchema["permissions"] = {
@@ -59,68 +42,31 @@ export const rolePermission2Form = (permissions: TPermission[] = []) => {
sso: {},
member: {},
"service-account": {},
"incident-contact": {}
"incident-contact": {},
"secret-scanning": {}
};
permissions.forEach((permission) => {
switch (permission.subject) {
case "workspace":
api2FormWorkspace(formVal?.workspace, permission);
break;
default:
// everything else follows same pattern
// formVal[settings][read | write] = true
formVal[permission.subject as keyof TFormSchema["permissions"]][permission.action] = true;
break;
}
// akhilmhdh: this is typecast as workspace key else i would need an if loop with same condition on both side
formVal[permission.subject][permission.action as keyof typeof formVal.workspace] = true;
});
return formVal;
};
const form2ApiWorkspace = (
permissions: TPermission[],
workspace: TFormSchema["permissions"]["workspace"]
) => {
const isFullAccess = PERMISSION_ACTIONS.every((action) => workspace?.all?.[action]);
// if any of them is set in all push it without any condition
PERMISSION_ACTIONS.forEach((action) => {
if (workspace?.all?.[action]) permissions.push({ action, subject: "workspace" });
});
if (!isFullAccess) {
Object.keys(workspace)
.filter((id) => id !== "all" && id !== "custom") // remove all and custom for iter
.forEach((workspaceId) => {
const actions = Object.keys(workspace[workspaceId]) as ["read", "edit", "create", "delete"];
actions.forEach((action) => {
// if not full access for an action
if (!workspace?.all?.[action] && workspace[workspaceId][action]) {
permissions.push({ action, subject: "workspace", condition: { id: workspaceId } });
}
});
});
}
};
export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
const permissions: TPermission[] = [];
if (formVal?.workspace) {
// easy deep copy
form2ApiWorkspace(permissions, JSON.parse(JSON.stringify(formVal.workspace)));
}
// other than workspace everything else follows same
// if in future there is a different follow the above on how workspace is done
const { workspace, ...rules } = formVal;
(Object.keys(rules) as Array<keyof typeof rules>).forEach((rule) => {
(Object.keys(formVal) as Array<keyof typeof formVal>).forEach((rule) => {
// all these type annotations are due to Object.keys of ts cannot infer and put it just a string[]
// quite annoying i know
const actions = Object.keys(rules[rule]) as Array<
const actions = Object.keys(formVal[rule]) as Array<
keyof z.infer<typeof generalPermissionSchema>
>;
actions.forEach((action) => {
if (rules[rule][action]) {
permissions.push({ action, subject: rule });
// 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);
}
});
});
@@ -1,6 +1,6 @@
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faLaptopCode } from "@fortawesome/free-solid-svg-icons";
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
@@ -30,10 +30,10 @@ const PERMISSIONS = [
{ action: "delete", label: "Remove" }
] as const;
export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: Props) => {
export const SecretScannigPermission = ({ isNonEditable, setValue, control }: Props) => {
const rule = useWatch({
control,
name: "permissions.service-account"
name: "permissions.secret-scanning"
});
const [isCustom, setIsCustom] = useToggle();
@@ -60,7 +60,7 @@ export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: P
case Permission.NoAccess:
setIsCustom.off();
setValue(
"permissions.service-account",
"permissions.secret-scanning",
{ read: false, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
@@ -68,7 +68,7 @@ export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: P
case Permission.FullAccess:
setIsCustom.off();
setValue(
"permissions.service-account",
"permissions.secret-scanning",
{ read: true, edit: true, create: true, delete: true },
{ shouldDirty: true }
);
@@ -76,7 +76,7 @@ export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: P
case Permission.ReadOnly:
setIsCustom.off();
setValue(
"permissions.service-account",
"permissions.secret-scanning",
{ read: true, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
@@ -84,7 +84,7 @@ export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: P
default:
setIsCustom.on();
setValue(
"permissions.service-account",
"permissions.secret-scanning",
{ read: false, edit: false, create: false, delete: false },
{ shouldDirty: true }
);
@@ -101,11 +101,11 @@ export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: P
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={faLaptopCode} className="text-4xl" />
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-4xl" />
</div>
<div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">Service Accounts</div>
<div className="text-xs font-light">Service Account management control</div>
<div className="font-medium mb-1 text-lg">Secret Scanning</div>
<div className="text-xs font-light">Secret scanning management control</div>
</div>
<div>
<Select
@@ -129,14 +129,14 @@ export const ServiceAccountPermission = ({ isNonEditable, setValue, control }: P
{isCustom &&
PERMISSIONS.map(({ action, label }) => (
<Controller
name={`permissions.service-account.${action}`}
key={`permissions.service-account.${action}`}
name={`permissions.role.${action}`}
key={`permissions.role.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.service-account.${action}`}
id={`permissions.role.${action}`}
isDisabled={isNonEditable}
>
{label}
@@ -1,23 +1,12 @@
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { faClipboardList } from "@fortawesome/free-solid-svg-icons";
import { faMoneyBill } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import {
Checkbox,
Select,
SelectItem,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { Checkbox, Select, SelectItem } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { TFormSchema } from "./OrgRoleModifySection.utils";
@@ -34,48 +23,50 @@ enum Permission {
Custom = "custom"
}
export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) => {
const { workspaces } = useWorkspace();
const PERMISSIONS = [
{ action: "read", label: "Read" },
{ action: "create", label: "Create" }
] as const;
const customWorkspaceRule = useWatch({
export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) => {
const rule = useWatch({
control,
name: "permissions.workspace.custom"
name: "permissions.workspace"
});
const isCustom = Boolean(customWorkspaceRule);
const allWorkspaceRule = useWatch({ control, name: "permissions.workspace.all" });
const [isCustom, setIsCustom] = useToggle();
const selectedPermissionCategory = useMemo(() => {
const { read, delete: del, edit, create } = allWorkspaceRule || {};
if (read && del && edit && create) return Permission.FullAccess;
if (read) return Permission.ReadOnly;
return Permission.NoAccess;
}, [allWorkspaceRule]);
let score = 0;
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
const totalActions = PERMISSIONS.length;
actions.forEach((key) => (score += rule[key] ? 1 : 0));
if (isCustom) return Permission.Custom;
if (score === 0) return Permission.NoAccess;
if (score === totalActions) return Permission.FullAccess;
if (score === 1 && rule.read) return Permission.ReadOnly;
return Permission.Custom;
}, [rule, isCustom]);
useEffect(() => {
selectedPermissionCategory === Permission.Custom ? setIsCustom.on() : setIsCustom.off();
}, [selectedPermissionCategory]);
const handlePermissionChange = (val: Permission) => {
val === Permission.Custom ? setIsCustom.on() : setIsCustom.off();
switch (val) {
case Permission.NoAccess:
setValue("permissions.workspace", {}, { shouldDirty: true });
setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true });
break;
case Permission.FullAccess:
setValue(
"permissions.workspace",
{ all: { read: true, edit: true, create: true, delete: true } },
{ shouldDirty: true }
);
setValue("permissions.workspace", { read: true, create: true }, { shouldDirty: true });
break;
case Permission.ReadOnly:
setValue(
"permissions.workspace",
{ all: { read: true, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
setValue("permissions.workspace", { read: true, create: false }, { shouldDirty: true });
break;
default:
setValue(
"permissions.workspace",
{ custom: { read: false, edit: false, create: false, delete: false } },
{ shouldDirty: true }
);
setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true });
break;
}
};
@@ -84,23 +75,22 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props)
<div
className={twMerge(
"px-10 py-6 bg-mineshaft-800 rounded-md",
(selectedPermissionCategory !== Permission.NoAccess || isCustom) &&
"border-l-2 border-primary-600"
selectedPermissionCategory !== Permission.NoAccess && "border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">
<div>
<FontAwesomeIcon icon={faClipboardList} className="text-4xl" />
<FontAwesomeIcon icon={faMoneyBill} className="text-4xl" />
</div>
<div className="flex-grow flex flex-col">
<div className="font-medium mb-1 text-lg">Projects</div>
<div className="text-xs font-light">User project access control</div>
<div className="font-medium mb-1 text-lg">Project</div>
<div className="text-xs font-light">Project management control</div>
</div>
<div>
<Select
defaultValue={Permission.NoAccess}
isDisabled={isNonEditable}
value={isCustom ? Permission.Custom : selectedPermissionCategory}
value={selectedPermissionCategory}
onValueChange={handlePermissionChange}
>
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
@@ -112,100 +102,27 @@ export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props)
</div>
<motion.div
initial={false}
animate={{ height: isCustom ? "auto" : 0 }}
className="overflow-hidden"
animate={{ height: isCustom ? "2.5rem" : 0, paddingTop: isCustom ? "1rem" : 0 }}
className="overflow-hidden grid gap-8 grid-flow-col auto-cols-min"
>
<TableContainer className="border-mineshaft-500 mt-6">
<Table>
<THead>
<Tr>
<Th />
<Th className="text-center">Read</Th>
<Th className="text-center">Create</Th>
<Th className="text-center">Edit</Th>
<Th className="text-center">Delete</Th>
</Tr>
</THead>
<TBody>
{isCustom &&
workspaces?.map(({ name, _id: id }) => (
<Tr key={`custom-role-ws-${name}`}>
<Td>{name}</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.read`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${id}.read`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.create`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.workspace.${id}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
name={`permissions.workspace.${id}.edit`}
control={control}
defaultValue={false}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
onBlur={field.onBlur}
id={`permissions.workspace.${id}.modify`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
<Td>
<Controller
defaultValue={false}
name={`permissions.workspace.${id}.delete`}
control={control}
render={({ field }) => (
<div className="flex items-center justify-center">
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${id}.delete`}
isDisabled={isNonEditable}
/>
</div>
)}
/>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
{isCustom &&
PERMISSIONS.map(({ action, label }) => (
<Controller
name={`permissions.workspace.${action}`}
key={`permissions.workspace.${action}`}
control={control}
render={({ field }) => (
<Checkbox
isChecked={field.value}
onCheckedChange={field.onChange}
id={`permissions.workspace.${action}`}
isDisabled={isNonEditable}
>
{label}
</Checkbox>
)}
/>
))}
</motion.div>
</div>
);