Merge pull request #2767 from akhilmhdh/feat/permission-error

Detail error when permission validation error occurs
This commit is contained in:
Akhil Mohan
2024-11-21 02:00:51 +05:30
committed by GitHub
13 changed files with 205 additions and 41 deletions
@@ -1,14 +1,7 @@
import picomatch from "picomatch"; import picomatch from "picomatch";
import { z } from "zod"; import { z } from "zod";
export enum PermissionConditionOperators { import { PermissionConditionOperators } from "@app/lib/casl";
$IN = "$in",
$ALL = "$all",
$REGEX = "$regex",
$EQ = "$eq",
$NEQ = "$ne",
$GLOB = "$glob"
}
export const PermissionConditionSchema = { export const PermissionConditionSchema = {
[PermissionConditionOperators.$IN]: z.string().trim().min(1).array(), [PermissionConditionOperators.$IN]: z.string().trim().min(1).array(),
@@ -1,10 +1,10 @@
import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability";
import { z } from "zod"; import { z } from "zod";
import { conditionsMatcher } from "@app/lib/casl"; import { conditionsMatcher, PermissionConditionOperators } from "@app/lib/casl";
import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission";
import { PermissionConditionOperators, PermissionConditionSchema } from "./permission-types"; import { PermissionConditionSchema } from "./permission-types";
export enum ProjectPermissionActions { export enum ProjectPermissionActions {
Read = "read", Read = "read",
+9
View File
@@ -54,3 +54,12 @@ export const isAtLeastAsPrivileged = (permissions1: MongoAbility, permissions2:
return set1.size >= set2.size; return set1.size >= set2.size;
}; };
export enum PermissionConditionOperators {
$IN = "$in",
$ALL = "$all",
$REGEX = "$regex",
$EQ = "$eq",
$NEQ = "$ne",
$GLOB = "$glob"
}
+8 -2
View File
@@ -1,4 +1,4 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError, PureAbility } from "@casl/ability";
import fastifyPlugin from "fastify-plugin"; import fastifyPlugin from "fastify-plugin";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { ZodError } from "zod"; import { ZodError } from "zod";
@@ -64,7 +64,13 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
void res.status(HttpStatusCodes.Forbidden).send({ void res.status(HttpStatusCodes.Forbidden).send({
statusCode: HttpStatusCodes.Forbidden, statusCode: HttpStatusCodes.Forbidden,
error: "PermissionDenied", error: "PermissionDenied",
message: `You are not allowed to ${error.action} on ${error.subjectType} - ${JSON.stringify(error.subject)}` message: `You are not allowed to ${error.action} on ${error.subjectType}`,
details: (error.ability as PureAbility).rulesFor(error.action as string, error.subjectType).map((el) => ({
action: el.action,
inverted: el.inverted,
subject: el.subject,
conditions: el.conditions
}))
}); });
} else if (error instanceof ForbiddenRequestError) { } else if (error instanceof ForbiddenRequestError) {
void res.status(HttpStatusCodes.Forbidden).send({ void res.status(HttpStatusCodes.Forbidden).send({
@@ -47,6 +47,7 @@ export const DefaultResponseErrorsSchema = {
403: z.object({ 403: z.object({
statusCode: z.literal(403), statusCode: z.literal(403),
message: z.string(), message: z.string(),
details: z.any().optional(),
error: z.string() error: z.string()
}), }),
500: z.object({ 500: z.object({
@@ -4,13 +4,15 @@ import { Id, toast, ToastContainer, ToastOptions, TypeOptions } from "react-toas
export type TNotification = { export type TNotification = {
title?: string; title?: string;
text: ReactNode; text: ReactNode;
children?: ReactNode;
}; };
export const NotificationContent = ({ title, text }: TNotification) => { export const NotificationContent = ({ title, text, children }: TNotification) => {
return ( return (
<div className="msg-container"> <div className="msg-container">
{title && <div className="text-md mb-1 font-medium">{title}</div>} {title && <div className="text-md mb-1 font-medium">{title}</div>}
<div className={title ? "text-sm" : "text-md"}>{text}</div> <div className={title ? "text-sm text-neutral-400" : "text-md"}>{text}</div>
{children && <div className="mt-2">{children}</div>}
</div> </div>
); );
}; };
@@ -23,7 +25,13 @@ export const createNotification = (
position: "bottom-right", position: "bottom-right",
...toastProps, ...toastProps,
theme: "dark", theme: "dark",
type: myProps?.type || "info", type: myProps?.type || "info"
}); });
export const NotificationContainer = () => <ToastContainer pauseOnHover toastClassName="border border-mineshaft-500" style={{ width: "400px" }} />; export const NotificationContainer = () => (
<ToastContainer
pauseOnHover
toastClassName="border border-mineshaft-500"
style={{ width: "400px" }}
/>
);
@@ -33,6 +33,15 @@ export enum PermissionConditionOperators {
$GLOB = "$glob" $GLOB = "$glob"
} }
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
[PermissionConditionOperators.$EQ]: "equal to",
[PermissionConditionOperators.$IN]: "contains",
[PermissionConditionOperators.$ALL]: "contains all",
[PermissionConditionOperators.$NEQ]: "not equal to",
[PermissionConditionOperators.$GLOB]: "matches glob pattern",
[PermissionConditionOperators.$REGEX]: "matches regex pattern"
};
export type TPermissionConditionOperators = { export type TPermissionConditionOperators = {
[PermissionConditionOperators.$IN]: string[]; [PermissionConditionOperators.$IN]: string[];
[PermissionConditionOperators.$ALL]: string[]; [PermissionConditionOperators.$ALL]: string[];
+7 -1
View File
@@ -1,3 +1,4 @@
import { PureAbility } from "@casl/ability";
import { ZodIssue } from "zod"; import { ZodIssue } from "zod";
export type { TAccessApprovalPolicy } from "./accessApproval/types"; export type { TAccessApprovalPolicy } from "./accessApproval/types";
@@ -52,9 +53,14 @@ export type TApiErrors =
| { | {
error: ApiErrorTypes.ValidationError; error: ApiErrorTypes.ValidationError;
message: ZodIssue[]; message: ZodIssue[];
statusCode: 401;
}
| {
error: ApiErrorTypes.ForbiddenError;
message: string;
details: PureAbility["rules"];
statusCode: 403; statusCode: 403;
} }
| { error: ApiErrorTypes.ForbiddenError; message: string; statusCode: 401 }
| { | {
statusCode: 400; statusCode: 400;
message: string; message: string;
+145 -21
View File
@@ -3,6 +3,14 @@ import axios from "axios";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
// akhilmhdh: doing individual imports to avoid cyclic import error
import { Button } from "./components/v2/Button";
import { Modal, ModalContent, ModalTrigger } from "./components/v2/Modal";
import { Table, TableContainer, TBody, Td, Th, THead, Tr } from "./components/v2/Table";
import {
formatedConditionsOperatorNames,
PermissionConditionOperators
} from "./context/ProjectPermissionContext/types";
import { ApiErrorTypes, TApiErrors } from "./hooks/api/types"; import { ApiErrorTypes, TApiErrors } from "./hooks/api/types";
// this is saved in react-query cache // this is saved in react-query cache
@@ -10,35 +18,151 @@ export const SIGNUP_TEMP_TOKEN_CACHE_KEY = ["infisical__signup-temp-token"];
export const MFA_TEMP_TOKEN_CACHE_KEY = ["infisical__mfa-temp-token"]; export const MFA_TEMP_TOKEN_CACHE_KEY = ["infisical__mfa-temp-token"];
export const AUTH_TOKEN_CACHE_KEY = ["infisical__auth-token"]; export const AUTH_TOKEN_CACHE_KEY = ["infisical__auth-token"];
const camelCaseToSpaces = (input: string) => {
return input.replace(/([a-z])([A-Z])/g, "$1 $2");
};
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onError: (error) => { onError: (error) => {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as TApiErrors; const serverResponse = error.response?.data as TApiErrors;
if (serverResponse?.error === ApiErrorTypes.ValidationError) { if (serverResponse?.error === ApiErrorTypes.ValidationError) {
createNotification({ createNotification(
title: "Validation Error", {
type: "error", title: "Validation Error",
text: ( type: "error",
<div> text: "Please check the input and try again.",
{serverResponse.message?.map(({ message, path }) => ( children: (
<div className="flex space-y-2" key={path.join(".")}> <Modal>
<div> <ModalTrigger>
Field <i>{path.join(".")}</i> {message.toLowerCase()} <Button variant="outline_bg" size="xs">
</div> Show more
</div> </Button>
))} </ModalTrigger>
</div> <ModalContent title="Validation Error Details">
) <TableContainer>
}); <Table>
<THead>
<Tr>
<Th>Field</Th>
<Th>Issue</Th>
</Tr>
</THead>
<TBody>
{serverResponse.message?.map(({ message, path }) => (
<Tr key={path.join(".")}>
<Td>{path.join(".")}</Td>
<Td>{message.toLowerCase()}</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
</ModalContent>
</Modal>
)
},
{ closeOnClick: false }
);
return; return;
} }
if (serverResponse.statusCode === 401) { if (serverResponse?.error === ApiErrorTypes.ForbiddenError) {
createNotification({ createNotification(
title: "Forbidden Access", {
type: "error", title: "Forbidden Access",
text: serverResponse.message type: "error",
}); text: serverResponse.message,
children: serverResponse?.details?.length ? (
<Modal>
<ModalTrigger>
<Button variant="outline_bg" size="xs">
Show more
</Button>
</ModalTrigger>
<ModalContent
title="Validation Rules"
subTitle="Please review the allowed rules below."
>
<div className="flex flex-col gap-2">
{serverResponse.details?.map((el, index) => {
const hasConditions = Object.keys(el.conditions || {}).length;
return (
<div
key={`Forbidden-error-details-${index + 1}`}
className="rounded-md border border-gray-600 p-4"
>
<div>
{el.inverted ? "Cannot" : "Can"}{" "}
<span className="text-yellow-600">
{el.action.toString().replaceAll(",", ", ")}
</span>{" "}
{el.subject.toString()} {hasConditions && "with conditions:"}
</div>
{hasConditions && (
<ul className="flex list-disc flex-col gap-1 pl-5 pt-2 text-sm">
{Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => {
const operators = (
el.conditions as Record<
string,
| string
| { [K in PermissionConditionOperators]: string | string[] }
>
)[field];
const formattedFieldName = camelCaseToSpaces(field).toLowerCase();
if (typeof operators === "string") {
return (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">equal to</span>{" "}
<span className="text-yellow-600">{operators}</span>
</li>
);
}
return Object.keys(operators).map((operator, operatorIndex) => (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}-${operatorIndex + 1}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
}
</span>{" "}
<span className="text-yellow-600">
{operators[
operator as PermissionConditionOperators
].toString()}
</span>
</li>
));
})}
</ul>
)}
</div>
);
})}
</div>
</ModalContent>
</Modal>
) : undefined
},
{ closeOnClick: false }
);
return; return;
} }
createNotification({ title: "Bad Request", type: "error", text: serverResponse.message }); createNotification({ title: "Bad Request", type: "error", text: serverResponse.message });
+8
View File
@@ -13,6 +13,14 @@ html {
@apply rounded-md; @apply rounded-md;
} }
.Toastify__toast-body {
@apply items-start;
}
.Toastify__toast-icon {
@apply w-4 pt-1;
}
.rdp-day, .rdp-day,
.rdp-nav_button { .rdp-nav_button {
@apply rounded-md hover:text-mineshaft-500; @apply rounded-md hover:text-mineshaft-500;
@@ -154,7 +154,7 @@ export const CreateSecretForm = ({
isMulti isMulti
name="tagIds" name="tagIds"
isDisabled={!canReadTags} isDisabled={!canReadTags}
isLoading={isTagsLoading} isLoading={isTagsLoading && canReadTags}
options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))} options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))}
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
@@ -867,7 +867,7 @@ export const SecretOverviewPage = () => {
<div className="thin-scrollbar mt-4"> <div className="thin-scrollbar mt-4">
<TableContainer <TableContainer
onScroll={(e) => setScrollOffset(e.currentTarget.scrollLeft)} onScroll={(e) => setScrollOffset(e.currentTarget.scrollLeft)}
className="thin-scrollbar" className="thin-scrollbar rounded-b-none"
> >
<Table> <Table>
<THead> <THead>
@@ -255,7 +255,7 @@ export const CreateSecretForm = ({ secretPath = "/", getSecretByKey, onClose }:
isMulti isMulti
name="tagIds" name="tagIds"
isDisabled={!canReadTags} isDisabled={!canReadTags}
isLoading={isTagsLoading} isLoading={isTagsLoading && canReadTags}
options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))} options={projectTags?.map((el) => ({ label: el.slug, value: el.id }))}
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}