mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 07:27:43 +00:00
Merge pull request #2767 from akhilmhdh/feat/permission-error
Detail error when permission validation error occurs
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+1
-1
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user