mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
fix(view-secret-value): backwards compatibility for read
This commit is contained in:
@@ -8,7 +8,8 @@ export enum ProjectPermissionActions {
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretActions {
|
||||
DescribeSecret = "read",
|
||||
DescribeAndReadValue = "read",
|
||||
DescribeSecret = "describeSecret",
|
||||
ReadValue = "readValue",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
|
||||
36
frontend/src/lib/fn/permission.ts
Normal file
36
frontend/src/lib/fn/permission.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { MongoAbility, subject } from "@casl/ability";
|
||||
|
||||
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
|
||||
import {
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSub,
|
||||
SecretSubjectFields
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
|
||||
export function secretsPermissionCan(
|
||||
permission: MongoAbility<ProjectPermissionSet>,
|
||||
action: Extract<
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretActions.DescribeSecret | ProjectPermissionSecretActions.ReadValue
|
||||
>,
|
||||
subjectFields?: SecretSubjectFields
|
||||
) {
|
||||
let canNewPermission = false;
|
||||
let canOldPermission = false;
|
||||
|
||||
if (subjectFields) {
|
||||
canNewPermission = permission.can(action, subject(ProjectPermissionSub.Secrets, subjectFields));
|
||||
canOldPermission = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, subjectFields)
|
||||
);
|
||||
} else {
|
||||
canNewPermission = permission.can(action, ProjectPermissionSub.Secrets);
|
||||
canOldPermission = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
}
|
||||
|
||||
return canNewPermission || canOldPermission;
|
||||
}
|
||||
@@ -303,13 +303,9 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
render={({ field: { onChange, value }, fieldState: { error } }) => {
|
||||
const options = [
|
||||
{
|
||||
label: "Describe Secret (default)",
|
||||
label: "Read (default)",
|
||||
value: "read"
|
||||
},
|
||||
{
|
||||
label: "Read Value (optional)",
|
||||
value: "readValue"
|
||||
},
|
||||
{
|
||||
label: "Write (optional)",
|
||||
value: "write"
|
||||
|
||||
@@ -35,12 +35,13 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
title,
|
||||
isDisabled
|
||||
}: Props<T>) => {
|
||||
const { control } = useFormContext<TFormSchema>();
|
||||
const { control, watch } = useFormContext<TFormSchema>();
|
||||
const items = useFieldArray({
|
||||
control,
|
||||
name: `permissions.${subject}`
|
||||
});
|
||||
const [isOpen, setIsOpen] = useToggle();
|
||||
// const [hideFullReadAccess, setHideFullReadAccess] = useState(false);
|
||||
|
||||
if (!items.fields.length) return <div />;
|
||||
|
||||
@@ -71,119 +72,138 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6">
|
||||
{items.fields.map((el, rootIndex) => (
|
||||
<div key={el.id} className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md">
|
||||
{isConditionalSubjects(subject) && (
|
||||
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
|
||||
<div className="w-1/4">Permission</div>
|
||||
<div className="mr-4 w-1/4">
|
||||
<Controller
|
||||
defaultValue={false as any}
|
||||
name={`permissions.${subject}.${rootIndex}.inverted`}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(val === "true")}
|
||||
containerClassName="w-full"
|
||||
className="w-full"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<SelectItem value="false">Allow</SelectItem>
|
||||
<SelectItem value="true">Forbid</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Whether to allow or forbid the selected actions when the following
|
||||
conditions (if any) are met.
|
||||
</p>
|
||||
<p className="mt-2">Forbid rules must come after allow rules.</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-gray-300">
|
||||
<div className="w-1/4">Actions</div>
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-8">
|
||||
{actions.map(({ label, value }) => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
{items.fields.map((el, rootIndex) => {
|
||||
let isFullReadAccessEnabled = false;
|
||||
|
||||
return (
|
||||
if (subject === ProjectPermissionSub.Secrets) {
|
||||
isFullReadAccessEnabled = watch(`permissions.${subject}.${rootIndex}.read` as any);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={el.id}
|
||||
className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md"
|
||||
>
|
||||
{isConditionalSubjects(subject) && (
|
||||
<div className="mb-6 mt-4 flex w-full items-center text-gray-300">
|
||||
<div className="w-1/4">Permission</div>
|
||||
<div className="mr-4 w-1/4">
|
||||
<Controller
|
||||
key={`${el.id}-${label}`}
|
||||
name={`permissions.${subject}.${rootIndex}.${value}` as any}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isDisabled={isDisabled}
|
||||
isChecked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
defaultValue={false as any}
|
||||
name={`permissions.${subject}.${rootIndex}.inverted`}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onValueChange={(val) => field.onChange(val === "true")}
|
||||
containerClassName="w-full"
|
||||
className="w-full"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<SelectItem value="false">Allow</SelectItem>
|
||||
<SelectItem value="true">Forbid</SelectItem>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
</div>
|
||||
<div>
|
||||
<Tooltip
|
||||
asChild
|
||||
content={
|
||||
<>
|
||||
<p>
|
||||
Whether to allow or forbid the selected actions when the following
|
||||
conditions (if any) are met.
|
||||
</p>
|
||||
<p className="mt-2">Forbid rules must come after allow rules.</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex text-gray-300">
|
||||
<div className="w-1/4">Actions</div>
|
||||
<div className="flex flex-grow flex-wrap justify-start gap-8">
|
||||
{actions.map(({ label, value }, index) => {
|
||||
if (typeof value !== "string") return undefined;
|
||||
|
||||
if (
|
||||
subject === ProjectPermissionSub.Secrets &&
|
||||
value === "read" &&
|
||||
!isFullReadAccessEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Controller
|
||||
key={`${el.id}-${index + 1}`}
|
||||
name={`permissions.${subject}.${rootIndex}.${value}` as any}
|
||||
control={control}
|
||||
defaultValue={false}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
isDisabled={isDisabled}
|
||||
isChecked={Boolean(field.value)}
|
||||
onCheckedChange={field.onChange}
|
||||
id={`permissions.${subject}.${rootIndex}.${String(value)}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{children &&
|
||||
cloneElement(children, {
|
||||
position: rootIndex
|
||||
})}
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex justify-start space-x-4",
|
||||
isConditionalSubjects(subject) && "justify-end"
|
||||
)}
|
||||
>
|
||||
{!isDisabled && isConditionalSubjects(subject) && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
items.insert(rootIndex + 1, [
|
||||
{ read: false, edit: false, create: false, delete: false } as any
|
||||
]);
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Add policy
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="mt-2 hover:border-red"
|
||||
onClick={() => items.remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove policy
|
||||
</Button>
|
||||
)}{" "}
|
||||
</div>
|
||||
</div>
|
||||
{children &&
|
||||
cloneElement(children, {
|
||||
position: rootIndex
|
||||
})}
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-4 flex justify-start space-x-4",
|
||||
isConditionalSubjects(subject) && "justify-end"
|
||||
)}
|
||||
>
|
||||
{!isDisabled && isConditionalSubjects(subject) && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
variant="star"
|
||||
size="xs"
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
items.insert(rootIndex + 1, [
|
||||
{ read: false, edit: false, create: false, delete: false } as any
|
||||
]);
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Add policy
|
||||
</Button>
|
||||
)}
|
||||
{!isDisabled && (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="mt-2 hover:border-red"
|
||||
onClick={() => items.remove(rootIndex)}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Remove policy
|
||||
</Button>
|
||||
)}{" "}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ReactNode } from "react";
|
||||
import { faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCmekActions,
|
||||
@@ -24,11 +28,12 @@ const GeneralPolicyActionSchema = z.object({
|
||||
});
|
||||
|
||||
const SecretPolicyActionSchema = z.object({
|
||||
read: z.boolean().optional(), // describe secret
|
||||
edit: z.boolean().optional(),
|
||||
delete: z.boolean().optional(),
|
||||
create: z.boolean().optional(),
|
||||
readValue: z.boolean().optional()
|
||||
[ProjectPermissionSecretActions.DescribeAndReadValue]: z.boolean().optional(), // existing read, gives both describe and read value
|
||||
[ProjectPermissionSecretActions.DescribeSecret]: z.boolean().optional(), // describe secret, cannot read value
|
||||
[ProjectPermissionSecretActions.ReadValue]: z.boolean().optional(), // read value
|
||||
[ProjectPermissionSecretActions.Edit]: z.boolean().optional(), // edit secret
|
||||
[ProjectPermissionSecretActions.Delete]: z.boolean().optional(), // delete secret
|
||||
[ProjectPermissionSecretActions.Create]: z.boolean().optional() // create secret
|
||||
});
|
||||
|
||||
const CmekPolicyActionSchema = z.object({
|
||||
@@ -294,19 +299,24 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
}
|
||||
|
||||
if (subject === ProjectPermissionSub.Secrets) {
|
||||
const canRead = action.includes(ProjectPermissionSecretActions.DescribeSecret);
|
||||
const canDescribeAndReadValue = action.includes(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue
|
||||
);
|
||||
const canDescribe = action.includes(ProjectPermissionSecretActions.DescribeSecret);
|
||||
const canReadValue = action.includes(ProjectPermissionSecretActions.ReadValue);
|
||||
|
||||
const canEdit = action.includes(ProjectPermissionSecretActions.Edit);
|
||||
const canDelete = action.includes(ProjectPermissionSecretActions.Delete);
|
||||
const canCreate = action.includes(ProjectPermissionSecretActions.Create);
|
||||
const canReadValue = action.includes(ProjectPermissionSecretActions.ReadValue);
|
||||
|
||||
// from above statement we are sure it won't be undefined
|
||||
formVal[subject]!.push({
|
||||
read: canRead,
|
||||
describeSecret: canDescribe,
|
||||
read: canDescribeAndReadValue,
|
||||
readValue: canReadValue,
|
||||
create: canCreate,
|
||||
edit: canEdit,
|
||||
delete: canDelete,
|
||||
readValue: canReadValue,
|
||||
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
|
||||
inverted
|
||||
});
|
||||
@@ -501,7 +511,7 @@ export type TProjectPermissionObject = {
|
||||
[K in ProjectPermissionSub]: {
|
||||
title: string;
|
||||
actions: {
|
||||
label: string;
|
||||
label: string | ReactNode;
|
||||
value: keyof Omit<
|
||||
NonNullable<NonNullable<TFormSchema["permissions"]>[K]>[number],
|
||||
"conditions" | "inverted"
|
||||
@@ -514,11 +524,35 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
||||
[ProjectPermissionSub.Secrets]: {
|
||||
title: "Secrets",
|
||||
actions: [
|
||||
{ label: "Describe Secret", value: "read" },
|
||||
{ label: "Create", value: "create" },
|
||||
{ label: "Read Value", value: "readValue" },
|
||||
{ label: "Modify", value: "edit" },
|
||||
{ label: "Remove", value: "delete" }
|
||||
{
|
||||
label: (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="opacity-60">
|
||||
Read <span className="text-xs opacity-80">(legacy)</span>
|
||||
</p>
|
||||
<Tooltip
|
||||
className="overflow-hidden whitespace-normal"
|
||||
content={
|
||||
<div>
|
||||
This is a legacy action and will be removed in the future.
|
||||
<br />
|
||||
<br /> You should instead use the{" "}
|
||||
<strong className="font-semibold">Describe Secret</strong> and{" "}
|
||||
<strong className="font-semibold">Read Value</strong> actions.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faWarning} className="mt-1 text-yellow-500" size="sm" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
value: ProjectPermissionSecretActions.DescribeAndReadValue
|
||||
},
|
||||
{ label: "Describe Secret", value: ProjectPermissionSecretActions.DescribeSecret },
|
||||
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
|
||||
{ label: "Modify", value: ProjectPermissionSecretActions.Edit },
|
||||
{ label: "Remove", value: ProjectPermissionSecretActions.Delete },
|
||||
{ label: "Create", value: ProjectPermissionSecretActions.Create }
|
||||
]
|
||||
},
|
||||
[ProjectPermissionSub.SecretFolders]: {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionCo
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { useUpdateSecretV3 } from "@app/hooks/api";
|
||||
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types";
|
||||
import { secretsPermissionCan } from "@app/lib/fn/permission";
|
||||
|
||||
enum SecretActionType {
|
||||
Created = "created",
|
||||
@@ -51,8 +52,11 @@ function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }
|
||||
secretTags: (secretDetails?.tags || []).map((i) => i.slug)
|
||||
});
|
||||
const isSecretInEnvReadOnly =
|
||||
permission.can(ProjectPermissionSecretActions.DescribeSecret, secretPermissionSubject) &&
|
||||
permission.cannot(ProjectPermissionSecretActions.Edit, secretPermissionSubject);
|
||||
secretsPermissionCan(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
secretPermissionSubject
|
||||
) && permission.cannot(ProjectPermissionSecretActions.Edit, secretPermissionSubject);
|
||||
if (isSecretInEnvReadOnly) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
|
||||
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { secretsPermissionCan } from "@app/lib/fn/permission";
|
||||
|
||||
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
|
||||
import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection";
|
||||
@@ -103,23 +104,27 @@ const Page = () => {
|
||||
const workspaceId = currentWorkspace?.id || "";
|
||||
const projectSlug = currentWorkspace?.slug || "";
|
||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||
const canReadSecret = permission.can(
|
||||
|
||||
const canReadSecret = secretsPermissionCan(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})
|
||||
}
|
||||
);
|
||||
const canReadSecretValue = permission.can(
|
||||
|
||||
const canReadSecretValue = secretsPermissionCan(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const canReadSecretImports = permission.can(
|
||||
|
||||
@@ -57,6 +57,7 @@ import { ActorType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
import { secretsPermissionCan } from "@app/lib/fn/permission";
|
||||
|
||||
import { CreateReminderForm } from "./CreateReminderForm";
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
@@ -140,26 +141,24 @@ export const SecretDetailSidebar = ({
|
||||
})
|
||||
);
|
||||
|
||||
const cannotReadSecretValue = permission.cannot(
|
||||
const cannotReadSecretValue = !secretsPermissionCan(
|
||||
permission,
|
||||
ProjectPermissionSecretActions.ReadValue,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
{
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: secretKey,
|
||||
secretTags: selectTagSlugs
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: secretKey,
|
||||
secretTags: selectTagSlugs
|
||||
})
|
||||
) &&
|
||||
secretsPermissionCan(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: secretKey,
|
||||
secretTags: selectTagSlugs
|
||||
}) &&
|
||||
cannotEditSecret &&
|
||||
cannotReadSecretValue;
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
|
||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||
import { Blur } from "@app/components/v2/Blur";
|
||||
import { secretsPermissionCan } from "@app/lib/fn/permission";
|
||||
import {
|
||||
FontAwesomeSpriteName,
|
||||
formSchema,
|
||||
@@ -131,15 +132,12 @@ export const SecretItem = memo(
|
||||
});
|
||||
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionSecretActions.DescribeSecret,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
})
|
||||
) &&
|
||||
secretsPermissionCan(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName,
|
||||
secretTags: selectedTagSlugs
|
||||
}) &&
|
||||
permission.cannot(
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
|
||||
Reference in New Issue
Block a user