merge deconflict

This commit is contained in:
Scott Wilson
2025-04-15 14:32:21 -07:00
237 changed files with 7423 additions and 1530 deletions
+4 -4
View File
@@ -129,7 +129,7 @@
"tailwindcss": "^3.4.16",
"typescript": "~5.6.2",
"typescript-eslint": "^8.15.0",
"vite": "^5.4.11",
"vite": "^5.4.18",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-top-level-await": "^1.4.4",
"vite-plugin-wasm": "^3.3.0",
@@ -13907,9 +13907,9 @@
}
},
"node_modules/vite": {
"version": "5.4.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz",
"integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==",
"version": "5.4.18",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz",
"integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -133,7 +133,7 @@
"tailwindcss": "^3.4.16",
"typescript": "~5.6.2",
"typescript-eslint": "^8.15.0",
"vite": "^5.4.11",
"vite": "^5.4.18",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-top-level-await": "^1.4.4",
"vite-plugin-wasm": "^3.3.0",
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { Modal, ModalContent } from "@app/components/v2";
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
@@ -10,6 +10,7 @@ import { SecretSyncSelect } from "./SecretSyncSelect";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
selectSync?: SecretSync | null;
};
type ContentProps = {
@@ -32,8 +33,12 @@ const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) =>
return <SecretSyncSelect onSelect={setSelectedSync} />;
};
export const CreateSecretSyncModal = ({ onOpenChange, ...props }: Props) => {
const [selectedSync, setSelectedSync] = useState<SecretSync | null>(null);
export const CreateSecretSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => {
const [selectedSync, setSelectedSync] = useState<SecretSync | null>(selectSync);
useEffect(() => {
setSelectedSync(selectSync);
}, [selectSync]);
return (
<Modal
@@ -12,6 +12,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields";
import { GcpSyncFields } from "./GcpSyncFields";
import { GitHubSyncFields } from "./GitHubSyncFields";
import { HumanitecSyncFields } from "./HumanitecSyncFields";
import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields";
import { VercelSyncFields } from "./VercelSyncFields";
export const SecretSyncDestinationFields = () => {
@@ -36,6 +37,8 @@ export const SecretSyncDestinationFields = () => {
return <DatabricksSyncFields />;
case SecretSync.Humanitec:
return <HumanitecSyncFields />;
case SecretSync.TerraformCloud:
return <TerraformCloudSyncFields />;
case SecretSync.Camunda:
return <CamundaSyncFields />;
case SecretSync.Vercel:
@@ -0,0 +1,244 @@
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2";
import {
TERRAFORM_CLOUD_SYNC_SCOPES,
TerraformCloudSyncCategory,
TerraformCloudSyncScope,
TTerraformCloudConnectionOrganization,
TTerraformCloudConnectionVariableSet,
TTerraformCloudConnectionWorkspace,
useTerraformCloudConnectionListOrganizations
} from "@app/hooks/api/appConnections/terraform-cloud";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
export const TerraformCloudSyncFields = () => {
const { control, watch, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.TerraformCloud }
>();
const connectionId = useWatch({ name: "connection.id", control });
const currentOrg = watch("destinationConfig.org");
const currentScope = watch("destinationConfig.scope");
const { data: organizations = [], isPending: isOrganizationsPending } =
useTerraformCloudConnectionListOrganizations(connectionId, {
enabled: Boolean(connectionId)
});
const selectedOrg = organizations?.find((org) => org.id === currentOrg);
const variableSets = selectedOrg?.variableSets || [];
const workspaces = selectedOrg?.workspaces || [];
return (
<>
<SecretSyncConnectionField
onChange={() => {
setValue("destinationConfig.org", "");
setValue("destinationConfig.variableSetId", "");
setValue("destinationConfig.workspaceId", "");
setValue("destinationConfig.variableSetName", "");
setValue("destinationConfig.workspaceName", "");
}}
/>
<Controller
name="destinationConfig.org"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Organization"
>
<FilterableSelect
isLoading={isOrganizationsPending && Boolean(connectionId)}
isDisabled={!connectionId}
value={organizations ? (organizations.find((org) => org.id === value) ?? null) : null}
onChange={(option) => {
onChange(
(option as SingleValue<TTerraformCloudConnectionOrganization>)?.id ?? null
);
setValue("destinationConfig.variableSetId", "");
setValue("destinationConfig.workspaceId", "");
setValue("destinationConfig.variableSetName", "");
setValue("destinationConfig.workspaceName", "");
}}
options={organizations}
placeholder="Select an organization..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
<Controller
name="destinationConfig.category"
control={control}
defaultValue={TerraformCloudSyncCategory.Environment}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Category"
tooltipClassName="max-w-lg py-3"
tooltipText={
<div className="flex flex-col gap-3">
<ul className="flex list-disc flex-col gap-3 pl-4">
<li>
<p className="text-mineshaft-300">
<span className="font-medium text-bunker-200">
Environment variables configure Terraform&apos;s behavior (e.g.,
credentials).
</span>
</p>
</li>
<li>
<p className="text-mineshaft-300">
<span className="font-medium text-bunker-200">
Terraform variables are used as input values in your configuration.
</span>
</p>
</li>
</ul>
</div>
}
>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
defaultValue={TerraformCloudSyncCategory.Environment}
placeholder="Select category..."
dropdownContainerClassName="max-w-none"
>
{Object.entries(TerraformCloudSyncCategory).map(([envKey, envValue]) => (
<SelectItem className="capitalize" value={envValue} key={envValue}>
{envKey.replace("-", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
name="destinationConfig.scope"
control={control}
defaultValue={TerraformCloudSyncScope.VariableSet}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Scope"
tooltipClassName="max-w-lg py-3"
tooltipText={
<div className="flex flex-col gap-3">
<p>
Specify how Infisical should manage secrets from Terraform Cloud. The following
options are available:
</p>
<ul className="flex list-disc flex-col gap-3 pl-4">
{Object.values(TERRAFORM_CLOUD_SYNC_SCOPES).map(({ name, description }) => {
return (
<li key={name}>
<p className="text-mineshaft-300">
<span className="font-medium text-bunker-200">{name}</span>: {description}
</p>
</li>
);
})}
</ul>
</div>
}
>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
setValue("destinationConfig.variableSetId", "");
setValue("destinationConfig.workspaceId", "");
setValue("destinationConfig.variableSetName", "");
setValue("destinationConfig.workspaceName", "");
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select a scope..."
dropdownContainerClassName="max-w-none"
>
{Object.values(TerraformCloudSyncScope).map((scope) => (
<SelectItem className="capitalize" value={scope} key={scope}>
{scope.replace("-", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{currentScope === TerraformCloudSyncScope.VariableSet && (
<Controller
name="destinationConfig.variableSetId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Variable Set">
<FilterableSelect
menuPlacement="top"
isLoading={isOrganizationsPending && Boolean(connectionId) && Boolean(currentOrg)}
isDisabled={!connectionId || !currentOrg}
value={variableSets.find((variableSet) => variableSet.id === value) ?? null}
onChange={(option) => {
const selectedOption =
option as SingleValue<TTerraformCloudConnectionVariableSet>;
onChange(selectedOption?.id ?? null);
if (selectedOption) {
setValue("destinationConfig.variableSetName", selectedOption.name);
} else {
setValue("destinationConfig.variableSetName", "");
}
}}
options={variableSets}
placeholder="Select a variable set..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
)}
{currentScope === TerraformCloudSyncScope.Workspace && (
<Controller
name="destinationConfig.workspaceId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Workspace">
<FilterableSelect
menuPlacement="top"
isLoading={isOrganizationsPending && Boolean(connectionId) && Boolean(currentOrg)}
isDisabled={!connectionId || !currentOrg}
value={workspaces.find((workspace) => workspace.id === value) ?? null}
onChange={(option) => {
const selectedOption = option as SingleValue<TTerraformCloudConnectionWorkspace>;
onChange(selectedOption?.id ?? null);
if (selectedOption) {
setValue("destinationConfig.workspaceName", selectedOption.name);
} else {
setValue("destinationConfig.workspaceName", "");
}
}}
options={workspaces}
placeholder="Select a workspace..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id.toString()}
/>
</FormControl>
)}
/>
)}
</>
);
};
@@ -39,6 +39,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
case SecretSync.AzureAppConfiguration:
case SecretSync.Databricks:
case SecretSync.Humanitec:
case SecretSync.TerraformCloud:
case SecretSync.Camunda:
case SecretSync.Vercel:
AdditionalSyncOptionsFieldsComponent = null;
@@ -22,6 +22,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields";
import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields";
import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields";
import { VercelSyncReviewFields } from "./VercelSyncReviewFields";
export const SecretSyncReviewFields = () => {
@@ -74,6 +75,9 @@ export const SecretSyncReviewFields = () => {
case SecretSync.Humanitec:
DestinationFieldsComponent = <HumanitecSyncReviewFields />;
break;
case SecretSync.TerraformCloud:
DestinationFieldsComponent = <TerraformCloudSyncReviewFields />;
break;
case SecretSync.Camunda:
DestinationFieldsComponent = <CamundaSyncReviewFields />;
break;
@@ -0,0 +1,28 @@
import { useFormContext } from "react-hook-form";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const TerraformCloudSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm & { destination: SecretSync.TerraformCloud }>();
const orgId = watch("destinationConfig.org");
const variableSetName = watch("destinationConfig.variableSetName");
const workspaceName = watch("destinationConfig.workspaceName");
const scope = watch("destinationConfig.scope");
const category = watch("destinationConfig.category");
return (
<>
<GenericFieldLabel label="Organization">{orgId}</GenericFieldLabel>
{scope === TerraformCloudSyncScope.VariableSet && (
<GenericFieldLabel label="Variable Set">{variableSetName}</GenericFieldLabel>
)}
{scope === TerraformCloudSyncScope.Workspace && (
<GenericFieldLabel label="Workspace">{workspaceName}</GenericFieldLabel>
)}
<GenericFieldLabel label="Category">{category}</GenericFieldLabel>
</>
);
};
@@ -10,6 +10,7 @@ import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-desti
import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema";
import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema";
import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema";
import { TerraformCloudSyncDestinationSchema } from "./terraform-cloud-destination-schema";
import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema";
const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
@@ -21,6 +22,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
AzureAppConfigurationSyncDestinationSchema,
DatabricksSyncDestinationSchema,
HumanitecSyncDestinationSchema,
TerraformCloudSyncDestinationSchema,
CamundaSyncDestinationSchema,
VercelSyncDestinationSchema
]);
@@ -0,0 +1,30 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import {
TerraformCloudSyncCategory,
TerraformCloudSyncScope
} from "@app/hooks/api/appConnections/terraform-cloud";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const TerraformCloudSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.TerraformCloud),
destinationConfig: z.discriminatedUnion("scope", [
z.object({
scope: z.literal(TerraformCloudSyncScope.VariableSet),
org: z.string().trim().min(1, "Organization required"),
variableSetId: z.string().trim().min(1, "Variable Set required"),
variableSetName: z.string().trim().min(1, "Variable set name required"),
category: z.nativeEnum(TerraformCloudSyncCategory)
}),
z.object({
scope: z.literal(TerraformCloudSyncScope.Workspace),
org: z.string().trim().min(1, "Organization required"),
workspaceId: z.string().trim().min(1, "Workspace required"),
workspaceName: z.string().trim().min(1, "Workspace name required"),
category: z.nativeEnum(TerraformCloudSyncCategory)
})
])
})
);
@@ -16,7 +16,9 @@ type Props = {
subTitle?: string;
onDeleteApproved: () => Promise<void>;
buttonText?: string;
formContent?: ReactNode;
children?: ReactNode;
deletionMessage?: ReactNode;
};
export const DeleteActionModal = ({
@@ -28,6 +30,8 @@ export const DeleteActionModal = ({
title,
subTitle = "This action is irreversible.",
buttonText = "Delete",
formContent,
deletionMessage,
children
}: Props): JSX.Element => {
const [inputData, setInputData] = useState("");
@@ -79,6 +83,7 @@ export const DeleteActionModal = ({
}
onClose={onClose}
>
{formContent}
<form
onSubmit={(evt) => {
evt.preventDefault();
@@ -88,7 +93,11 @@ export const DeleteActionModal = ({
<FormControl
label={
<div className="break-words pb-2 text-sm">
Type <span className="font-bold">{deleteKey}</span> to perform this action
{deletionMessage || (
<>
Type <span className="font-bold">{deleteKey}</span> to perform this action
</>
)}
</div>
}
className="mb-0"
@@ -30,7 +30,9 @@ export enum ProjectPermissionCmekActions {
Edit = "edit",
Delete = "delete",
Encrypt = "encrypt",
Decrypt = "decrypt"
Decrypt = "decrypt",
Sign = "sign",
Verify = "verify"
}
export enum ProjectPermissionKmipActions {
@@ -98,7 +100,8 @@ export enum PermissionConditionOperators {
$REGEX = "$regex",
$EQ = "$eq",
$NEQ = "$ne",
$GLOB = "$glob"
$GLOB = "$glob",
$ELEMENTMATCH = "$elemMatch"
}
export type IdentityManagementSubjectFields = {
@@ -111,7 +114,8 @@ export const formatedConditionsOperatorNames: { [K in PermissionConditionOperato
[PermissionConditionOperators.$ALL]: "contains all",
[PermissionConditionOperators.$NEQ]: "not equal to",
[PermissionConditionOperators.$GLOB]: "matches glob pattern",
[PermissionConditionOperators.$REGEX]: "matches regex pattern"
[PermissionConditionOperators.$REGEX]: "matches regex pattern",
[PermissionConditionOperators.$ELEMENTMATCH]: "element matches"
};
export type TPermissionConditionOperators = {
@@ -121,12 +125,24 @@ export type TPermissionConditionOperators = {
[PermissionConditionOperators.$NEQ]: string;
[PermissionConditionOperators.$REGEX]: string;
[PermissionConditionOperators.$GLOB]: string;
[PermissionConditionOperators.$ELEMENTMATCH]: Record<
string,
Partial<TPermissionConditionOperators>
>;
};
export type TPermissionCondition = Record<
string,
| string
| { $in: string[]; $all: string[]; $regex: string; $eq: string; $ne: string; $glob: string }
| {
$in: string[];
$all: string[];
$regex: string;
$eq: string;
$ne: string;
$glob: string;
$elemMatch: Partial<TPermissionCondition>;
}
>;
export enum ProjectPermissionSub {
@@ -180,6 +196,7 @@ export type SecretFolderSubjectFields = {
export type DynamicSecretSubjectFields = {
environment: string;
secretPath: string;
metadata?: (string | { key: string; value: string })[];
};
export type SecretImportSubjectFields = {
+3
View File
@@ -15,6 +15,7 @@ import {
MsSqlConnectionMethod,
PostgresConnectionMethod,
TAppConnection,
TerraformCloudConnectionMethod,
VercelConnectionMethod
} from "@app/hooks/api/appConnections/types";
@@ -35,6 +36,7 @@ export const APP_CONNECTION_MAP: Record<
},
[AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" },
[AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" },
[AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" },
[AppConnection.Vercel]: { name: "Vercel", image: "Vercel.png" },
[AppConnection.Postgres]: { name: "PostgreSQL", image: "Postgres.png" },
[AppConnection.MsSql]: { name: "Microsoft SQL Server", image: "MsSql.png" },
@@ -61,6 +63,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"])
case CamundaConnectionMethod.ClientCredentials:
return { name: "Client Credentials", icon: faKey };
case HumanitecConnectionMethod.ApiToken:
case TerraformCloudConnectionMethod.ApiToken:
case VercelConnectionMethod.ApiToken:
return { name: "API Token", icon: faKey };
case PostgresConnectionMethod.UsernameAndPassword:
+27
View File
@@ -0,0 +1,27 @@
import { AsymmetricKeyAlgorithm, KmsKeyUsage, SymmetricKeyAlgorithm } from "@app/hooks/api/cmeks";
export const kmsKeyUsageOptions: Record<
KmsKeyUsage,
{
label: string;
tooltip: string;
}
> = {
[KmsKeyUsage.ENCRYPT_DECRYPT]: {
label: "Encrypt/Decrypt",
tooltip: "Use the key only to encrypt and decrypt data."
},
[KmsKeyUsage.SIGN_VERIFY]: {
label: "Sign/Verify",
tooltip:
"Key pairs for digital signing. Uses the private key for signing and the public key for verification."
}
};
export const keyUsageDefaultOption: Record<
KmsKeyUsage,
SymmetricKeyAlgorithm | AsymmetricKeyAlgorithm
> = {
[KmsKeyUsage.ENCRYPT_DECRYPT]: SymmetricKeyAlgorithm.AES_GCM_256,
[KmsKeyUsage.SIGN_VERIFY]: AsymmetricKeyAlgorithm.RSA_4096
};
+5
View File
@@ -24,6 +24,10 @@ export const SECRET_SYNC_MAP: Record<SecretSync, { name: string; image: string }
name: "Humanitec",
image: "Humanitec.png"
},
[SecretSync.TerraformCloud]: {
name: "Terraform Cloud",
image: "Terraform Cloud.png"
},
[SecretSync.Camunda]: {
name: "Camunda",
image: "Camunda.png"
@@ -43,6 +47,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration,
[SecretSync.Databricks]: AppConnection.Databricks,
[SecretSync.Humanitec]: AppConnection.Humanitec,
[SecretSync.TerraformCloud]: AppConnection.TerraformCloud,
[SecretSync.Camunda]: AppConnection.Camunda,
[SecretSync.Vercel]: AppConnection.Vercel
};
@@ -6,6 +6,7 @@ export enum AppConnection {
AzureAppConfiguration = "azure-app-configuration",
Databricks = "databricks",
Humanitec = "humanitec",
TerraformCloud = "terraform-cloud",
Vercel = "vercel",
Postgres = "postgres",
MsSql = "mssql",
@@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";
@@ -0,0 +1,37 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "../queries";
import { TTerraformCloudOrganization } from "./types";
const terraformCloudConnectionKeys = {
all: [...appConnectionKeys.all, "terraform-cloud"] as const,
listOrganizations: (connectionId: string) =>
[...terraformCloudConnectionKeys.all, "organizations", connectionId] as const
};
export const useTerraformCloudConnectionListOrganizations = (
connectionId: string,
options?: Omit<
UseQueryOptions<
TTerraformCloudOrganization[],
unknown,
TTerraformCloudOrganization[],
ReturnType<typeof terraformCloudConnectionKeys.listOrganizations>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: terraformCloudConnectionKeys.listOrganizations(connectionId),
queryFn: async () => {
const { data } = await apiRequest.get<TTerraformCloudOrganization[]>(
`/api/v1/app-connections/terraform-cloud/${connectionId}/organizations`
);
return data;
},
...options
});
};
@@ -0,0 +1,56 @@
export type TTerraformCloudOrganization = {
name: string;
id: string;
variableSets: TTerraformCloudVariableSet[];
workspaces: TTerraformCloudWorkspace[];
};
export type TTerraformCloudVariableSet = {
id: string;
name: string;
};
export type TTerraformCloudWorkspace = {
id: string;
name: string;
};
export type TTerraformCloudConnectionOrganization = {
id: string;
name: string;
variableSets: TTerraformCloudConnectionVariableSet[];
workspaces: TTerraformCloudConnectionWorkspace[];
};
export type TTerraformCloudConnectionVariableSet = {
id: string;
name: string;
description: string;
global: boolean;
};
export type TTerraformCloudConnectionWorkspace = {
id: string;
name: string;
};
export enum TerraformCloudSyncScope {
VariableSet = "variable-set",
Workspace = "workspace"
}
export enum TerraformCloudSyncCategory {
Environment = "env",
Terraform = "terraform"
}
export const TERRAFORM_CLOUD_SYNC_SCOPES = {
[TerraformCloudSyncScope.VariableSet]: {
name: "Variable Set",
description: "Sync secrets to a specific variable set in Terraform Cloud."
},
[TerraformCloudSyncScope.Workspace]: {
name: "Workspace",
description: "Sync secrets to a specific workspace in Terraform Cloud."
}
};
@@ -39,6 +39,10 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Humanitec;
};
export type TTerraformCloudConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.TerraformCloud;
};
export type TVercelConnectionOption = TAppConnectionOptionBase & {
app: AppConnection.Vercel;
};
@@ -67,6 +71,7 @@ export type TAppConnectionOption =
| TAzureKeyVaultConnectionOption
| TDatabricksConnectionOption
| THumanitecConnectionOption
| TTerraformCloudConnectionOption
| TVercelConnectionOption
| TPostgresConnectionOption
| TMsSqlConnectionOption
@@ -81,6 +86,7 @@ export type TAppConnectionOptionMap = {
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption;
[AppConnection.Databricks]: TDatabricksConnectionOption;
[AppConnection.Humanitec]: THumanitecConnectionOption;
[AppConnection.TerraformCloud]: TTerraformCloudConnectionOption;
[AppConnection.Vercel]: TVercelConnectionOption;
[AppConnection.Postgres]: TPostgresConnectionOption;
[AppConnection.MsSql]: TMsSqlConnectionOption;
@@ -11,6 +11,7 @@ import { TGitHubConnection } from "./github-connection";
import { THumanitecConnection } from "./humanitec-connection";
import { TMsSqlConnection } from "./mssql-connection";
import { TPostgresConnection } from "./postgres-connection";
import { TTerraformCloudConnection } from "./terraform-cloud-connection";
import { TVercelConnection } from "./vercel-connection";
export * from "./auth0-connection";
@@ -24,6 +25,7 @@ export * from "./github-connection";
export * from "./humanitec-connection";
export * from "./mssql-connection";
export * from "./postgres-connection";
export * from "./terraform-cloud-connection";
export * from "./vercel-connection";
export type TAppConnection =
@@ -34,6 +36,7 @@ export type TAppConnection =
| TAzureAppConfigurationConnection
| TDatabricksConnection
| THumanitecConnection
| TTerraformCloudConnection
| TVercelConnection
| TPostgresConnection
| TMsSqlConnection
@@ -73,6 +76,7 @@ export type TAppConnectionMap = {
[AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection;
[AppConnection.Databricks]: TDatabricksConnection;
[AppConnection.Humanitec]: THumanitecConnection;
[AppConnection.TerraformCloud]: TTerraformCloudConnection;
[AppConnection.Vercel]: TVercelConnection;
[AppConnection.Postgres]: TPostgresConnection;
[AppConnection.MsSql]: TMsSqlConnection;
@@ -0,0 +1,15 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection";
export enum TerraformCloudConnectionMethod {
ApiToken = "api-token"
}
export type TTerraformCloudConnection = TRootAppConnection & {
app: AppConnection.TerraformCloud;
} & {
method: TerraformCloudConnectionMethod.ApiToken;
credentials: {
apiToken: string;
};
};
@@ -106,6 +106,10 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.GET_CMEK]: "Get KMS key",
[EventType.CMEK_ENCRYPT]: "Encrypt with KMS key",
[EventType.CMEK_DECRYPT]: "Decrypt with KMS key",
[EventType.CMEK_SIGN]: "Sign with KMS key",
[EventType.CMEK_VERIFY]: "Verify with KMS key",
[EventType.CMEK_LIST_SIGNING_ALGORITHMS]: "List signing algorithms for KMS key",
[EventType.CMEK_GET_PUBLIC_KEY]: "Get public key for KMS key",
[EventType.UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]:
"Update SSO group to organization role mapping",
[EventType.GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS]: "List SSO group to organization role mapping",
@@ -110,6 +110,10 @@ export enum EventType {
GET_CMEK = "get-cmek",
CMEK_ENCRYPT = "cmek-encrypt",
CMEK_DECRYPT = "cmek-decrypt",
CMEK_SIGN = "cmek-sign",
CMEK_VERIFY = "cmek-verify",
CMEK_LIST_SIGNING_ALGORITHMS = "cmek-list-signing-algorithms",
CMEK_GET_PUBLIC_KEY = "cmek-get-public-key",
UPDATE_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "update-external-group-org-role-mapping",
GET_EXTERNAL_GROUP_ORG_ROLE_MAPPINGS = "get-external-group-org-role-mapping",
GET_PROJECT_TEMPLATES = "get-project-templates",
@@ -8,6 +8,10 @@ import {
TCmekDecryptResponse,
TCmekEncrypt,
TCmekEncryptResponse,
TCmekSign,
TCmekSignResponse,
TCmekVerify,
TCmekVerifyResponse,
TCreateCmek,
TDeleteCmek,
TUpdateCmek
@@ -74,6 +78,44 @@ export const useCmekEncrypt = () => {
});
};
export const useCmekSign = () => {
return useMutation({
mutationFn: async ({
keyId,
data,
signingAlgorithm,
isBase64Encoded
}: TCmekSign & { isBase64Encoded: boolean }) => {
const res = await apiRequest.post<TCmekSignResponse>(`/api/v1/kms/keys/${keyId}/sign`, {
data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)),
signingAlgorithm
});
return res.data;
}
});
};
export const useCmekVerify = () => {
return useMutation({
mutationFn: async ({
keyId,
data,
signature,
signingAlgorithm,
isBase64Encoded
}: TCmekVerify & { isBase64Encoded: boolean }) => {
const res = await apiRequest.post<TCmekVerifyResponse>(`/api/v1/kms/keys/${keyId}/verify`, {
data: isBase64Encoded ? data : encodeBase64(Buffer.from(data)),
signature,
signingAlgorithm
});
return res.data;
}
});
};
export const useCmekDecrypt = () => {
return useMutation({
mutationFn: async ({ keyId, ciphertext }: TCmekDecrypt) => {
+59 -3
View File
@@ -1,10 +1,18 @@
import { z } from "zod";
import { OrderByDirection } from "@app/hooks/api/generic/types";
export enum KmsKeyUsage {
ENCRYPT_DECRYPT = "encrypt-decrypt",
SIGN_VERIFY = "sign-verify"
}
export type TCmek = {
id: string;
keyUsage: KmsKeyUsage;
name: string;
description?: string;
encryptionAlgorithm: EncryptionAlgorithm;
encryptionAlgorithm: AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm;
projectId: string;
isDisabled: boolean;
isReserved: boolean;
@@ -17,7 +25,8 @@ export type TCmek = {
type ProjectRef = { projectId: string };
type KeyRef = { keyId: string };
export type TCreateCmek = Pick<TCmek, "name" | "description" | "encryptionAlgorithm"> & ProjectRef;
export type TCreateCmek = Pick<TCmek, "name" | "description" | "encryptionAlgorithm" | "keyUsage"> &
ProjectRef;
export type TUpdateCmek = KeyRef &
Partial<Pick<TCmek, "name" | "description" | "isDisabled">> &
ProjectRef;
@@ -26,6 +35,13 @@ export type TDeleteCmek = KeyRef & ProjectRef;
export type TCmekEncrypt = KeyRef & { plaintext: string; isBase64Encoded?: boolean };
export type TCmekDecrypt = KeyRef & { ciphertext: string };
export type TCmekSign = KeyRef & { data: string; signingAlgorithm: SigningAlgorithm };
export type TCmekVerify = KeyRef & {
data: string;
signature: string;
signingAlgorithm: SigningAlgorithm;
};
export type TProjectCmeksList = {
keys: TCmek[];
totalCount: number;
@@ -44,6 +60,18 @@ export type TCmekEncryptResponse = {
ciphertext: string;
};
export type TCmekSignResponse = {
signature: string;
keyId: string;
signingAlgorithm: SigningAlgorithm;
};
export type TCmekVerifyResponse = {
signatureValid: boolean;
keyId: string;
signingAlgorithm: SigningAlgorithm;
};
export type TCmekDecryptResponse = {
plaintext: string;
};
@@ -52,7 +80,35 @@ export enum CmekOrderBy {
Name = "name"
}
export enum EncryptionAlgorithm {
export enum AsymmetricKeyAlgorithm {
RSA_4096 = "RSA_4096",
ECC_NIST_P256 = "ECC_NIST_P256"
}
// Supported symmetric encrypt/decrypt algorithms
export enum SymmetricKeyAlgorithm {
AES_GCM_256 = "aes-256-gcm",
AES_GCM_128 = "aes-128-gcm"
}
export const AllowedEncryptionKeyAlgorithms = z.enum([
...Object.values(SymmetricKeyAlgorithm),
...Object.values(AsymmetricKeyAlgorithm)
] as [string, ...string[]]).options;
export enum SigningAlgorithm {
// RSA PSS algorithms
RSASSA_PSS_SHA_256 = "RSASSA_PSS_SHA_256",
RSASSA_PSS_SHA_384 = "RSASSA_PSS_SHA_384",
RSASSA_PSS_SHA_512 = "RSASSA_PSS_SHA_512",
// RSA PKCS#1 v1.5 algorithms
RSASSA_PKCS1_V1_5_SHA_256 = "RSASSA_PKCS1_V1_5_SHA_256",
RSASSA_PKCS1_V1_5_SHA_384 = "RSASSA_PKCS1_V1_5_SHA_384",
RSASSA_PKCS1_V1_5_SHA_512 = "RSASSA_PKCS1_V1_5_SHA_512",
// ECDSA algorithms
ECDSA_SHA_256 = "ECDSA_SHA_256",
ECDSA_SHA_384 = "ECDSA_SHA_384",
ECDSA_SHA_512 = "ECDSA_SHA_512"
}
+11
View File
@@ -24,6 +24,7 @@ export type DashboardProjectSecretsOverviewResponse = {
totalUniqueDynamicSecretsInPage: number;
totalUniqueFoldersInPage: number;
totalUniqueSecretImportsInPage: number;
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
totalUniqueSecretRotationsInPage: number;
};
@@ -41,6 +42,16 @@ export type DashboardProjectSecretsDetailsResponse = {
totalSecretCount?: number;
totalSecretRotationCount?: number;
totalCount: number;
importedBy?: ProjectSecretsImportedBy[];
};
export type ProjectSecretsImportedBy = {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
isImported: boolean;
}[];
};
export type DashboardProjectSecretsByKeys = {
@@ -13,6 +13,7 @@ export type TDynamicSecret = {
status?: DynamicSecretStatus;
statusDetails?: string;
maxTTL: string;
metadata?: { key: string; value: string }[];
};
export enum DynamicSecretProviders {
@@ -261,6 +262,7 @@ export type TDynamicSecretProvider =
digits?: number;
};
};
export type TCreateDynamicSecretDTO = {
projectSlug: string;
provider: TDynamicSecretProvider;
@@ -269,6 +271,7 @@ export type TCreateDynamicSecretDTO = {
path: string;
environmentSlug: string;
name: string;
metadata?: { key: string; value: string }[];
};
export type TUpdateDynamicSecretDTO = {
@@ -278,6 +281,7 @@ export type TUpdateDynamicSecretDTO = {
environmentSlug: string;
data: {
newName?: string;
metadata?: { key: string; value: string }[];
defaultTTL?: string;
maxTTL?: string | null;
inputs?: unknown;
@@ -1,6 +1,7 @@
export type TCloudIntegration = {
name: string;
slug: string;
syncSlug?: string;
image: string;
isAvailable: boolean;
type: string;
@@ -45,6 +45,7 @@ export type TSecretRotationProviderTemplate = {
image?: string;
description?: string;
template: THttpProviderTemplate | TDbProviderTemplate;
isDeprecated?: boolean;
};
export type THttpProviderTemplate = {
@@ -7,6 +7,7 @@ export enum SecretSync {
AzureAppConfiguration = "azure-app-configuration",
Databricks = "databricks",
Humanitec = "humanitec",
TerraformCloud = "terraform-cloud",
Camunda = "camunda",
Vercel = "vercel"
}
@@ -10,6 +10,7 @@ import { TAzureKeyVaultSync } from "./azure-key-vault-sync";
import { TCamundaSync } from "./camunda-sync";
import { TGcpSync } from "./gcp-sync";
import { THumanitecSync } from "./humanitec-sync";
import { TTerraformCloudSync } from "./terraform-cloud-sync";
import { TVercelSync } from "./vercel-sync";
export type TSecretSyncOption = {
@@ -27,6 +28,7 @@ export type TSecretSync =
| TAzureAppConfigurationSync
| TDatabricksSync
| THumanitecSync
| TTerraformCloudSync
| TCamundaSync
| TVercelSync;
@@ -0,0 +1,34 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
import { TerraformCloudSyncCategory } from "../../appConnections/terraform-cloud";
export type TTerraformCloudSync = TRootSecretSync & {
destination: SecretSync.TerraformCloud;
destinationConfig:
| {
scope: TerraformCloudSyncScope.VariableSet;
org: string;
category: TerraformCloudSyncCategory;
variableSetId: string;
variableSetName: string;
}
| {
scope: TerraformCloudSyncScope.Workspace;
org: string;
category: TerraformCloudSyncCategory;
workspaceId: string;
workspaceName: string;
};
connection: {
app: AppConnection.TerraformCloud;
name: string;
id: string;
};
};
export enum TerraformCloudSyncScope {
VariableSet = "variable-set",
Workspace = "workspace"
}
+14
View File
@@ -0,0 +1,14 @@
const base64WithPadding =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$/;
export const isBase64 = (str: string): boolean => {
if (typeof str !== "string") {
throw new TypeError("Expected a string");
}
if (str === "") return true;
const regex = base64WithPadding;
return regex.test(str);
};
@@ -15,13 +15,23 @@ import {
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks";
import { keyUsageDefaultOption, kmsKeyUsageOptions } from "@app/helpers/kms";
import {
AllowedEncryptionKeyAlgorithms,
AsymmetricKeyAlgorithm,
KmsKeyUsage,
SymmetricKeyAlgorithm,
TCmek,
useCreateCmek,
useUpdateCmek
} from "@app/hooks/api/cmeks";
import { slugSchema } from "@app/lib/schemas";
const formSchema = z.object({
name: slugSchema({ min: 1, max: 32, field: "Name" }),
description: z.string().max(500).optional(),
encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm)
encryptionAlgorithm: z.enum(AllowedEncryptionKeyAlgorithms),
keyUsage: z.nativeEnum(KmsKeyUsage)
});
export type FormData = z.infer<typeof formSchema>;
@@ -47,24 +57,33 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
control,
handleSubmit,
register,
setValue,
watch,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: cmek?.name,
description: cmek?.description,
encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256
encryptionAlgorithm: SymmetricKeyAlgorithm.AES_GCM_256,
keyUsage: KmsKeyUsage.ENCRYPT_DECRYPT
}
});
const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => {
const handleCreateCmek = async ({
encryptionAlgorithm,
name,
description,
keyUsage
}: FormData) => {
const mutation = isUpdate
? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description })
: createCmek.mutateAsync({
projectId,
encryptionAlgorithm,
name,
description
description,
keyUsage,
encryptionAlgorithm: encryptionAlgorithm as AsymmetricKeyAlgorithm | SymmetricKeyAlgorithm
});
try {
@@ -83,6 +102,8 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
}
};
const selectedKeyUsage = watch("keyUsage");
return (
<form onSubmit={handleSubmit(handleCreateCmek)}>
<FormControl
@@ -93,23 +114,97 @@ const CmekForm = ({ onComplete, cmek }: FormProps) => {
>
<Input autoFocus placeholder="my-secret-key" {...register("name")} />
</FormControl>
{!isUpdate && (
<Controller
control={control}
name="encryptionAlgorithm"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Algorithm" errorText={error?.message} isError={Boolean(error)}>
<Select defaultValue={field.value} onValueChange={onChange} className="w-full">
{Object.entries(EncryptionAlgorithm)?.map(([key, value]) => (
<SelectItem value={value} key={`source-environment-${key}`}>
{key.replaceAll("_", "-")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<div className="flex w-full items-center gap-2">
{!isUpdate && (
<>
<Controller
control={control}
name="keyUsage"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="w-full"
tooltipText={
<div className="space-y-4">
{Object.entries(KmsKeyUsage).map(([key, value]) => (
<div key={`key-usage-${key}`}>
<p className="font-bold">{kmsKeyUsageOptions[value].label}</p>
<p>{kmsKeyUsageOptions[value].tooltip}</p>
</div>
))}
</div>
}
label="Key Usage"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
onValueChange={(e) => {
if (keyUsageDefaultOption[e as KmsKeyUsage]) {
setValue("encryptionAlgorithm", keyUsageDefaultOption[e as KmsKeyUsage], {
shouldDirty: true,
shouldValidate: true
});
}
onChange(e);
}}
className="w-full"
>
{Object.entries(KmsKeyUsage)?.map(([key, value]) => (
<SelectItem value={value} key={`key-usage-${key}`}>
{kmsKeyUsageOptions[value].label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="encryptionAlgorithm"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="w-full"
label="Algorithm"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
value={field.value}
onValueChange={onChange}
className="w-full"
>
{Object.entries(AllowedEncryptionKeyAlgorithms)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
?.filter(([_, value]) => {
if (selectedKeyUsage === KmsKeyUsage.ENCRYPT_DECRYPT) {
return Object.values(SymmetricKeyAlgorithm).includes(
value as unknown as SymmetricKeyAlgorithm
);
}
if (selectedKeyUsage === KmsKeyUsage.SIGN_VERIFY) {
return Object.values(AsymmetricKeyAlgorithm).includes(
value as unknown as AsymmetricKeyAlgorithm
);
}
return false;
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(([_, value]) => (
<SelectItem value={value} key={`encryption-algorithm-${value}`}>
<span className="uppercase">{value.replaceAll("-", " ")}</span>
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</>
)}
</div>
<FormControl
label="Description (optional)"
errorText={errors.description?.message}
@@ -0,0 +1,195 @@
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle, faFileSignature, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { SigningAlgorithm, TCmek, useCmekSign } from "@app/hooks/api/cmeks";
const formSchema = z.object({
data: z.string(),
signingAlgorithm: z.nativeEnum(SigningAlgorithm),
isBase64Encoded: z.boolean()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek: TCmek;
};
type FormProps = Pick<Props, "cmek">;
const SignForm = ({ cmek }: FormProps) => {
const cmekSign = useCmekSign();
const {
handleSubmit,
register,
control,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA")
? SigningAlgorithm.RSASSA_PSS_SHA_512
: SigningAlgorithm.ECDSA_SHA_256,
isBase64Encoded: false
}
});
const [copySignature, isCopyingSignature, setCopySignature] = useTimedReset<string>({
initialState: "Copy to Clipboard"
});
const handleSignData = async (formData: FormData) => {
try {
await cmekSign.mutateAsync({ ...formData, keyId: cmek.id });
createNotification({
text: "Successfully signed data",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to sign data",
type: "error"
});
}
};
const signature = cmekSign.data?.signature;
const handleCopyToClipboard = () => {
navigator.clipboard.writeText(signature ?? "");
setCopySignature("Copied to Clipboard");
};
const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) =>
cmek?.encryptionAlgorithm?.startsWith("RSA")
? a.toLowerCase().startsWith("rsa")
: a.toLowerCase().startsWith("ecdsa")
);
return (
<form onSubmit={handleSubmit(handleSignData)}>
{signature ? (
<FormControl label="Data Signature">
<TextArea
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
isDisabled
value={signature}
/>
</FormControl>
) : (
<>
<FormControl
label="Data to Sign"
errorText={errors.data?.message}
isError={Boolean(errors.data)}
>
<TextArea
{...register("data")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<div className="mb-6 flex w-full items-center justify-between gap-2">
<Controller
control={control}
name="signingAlgorithm"
render={({ field: { onChange, value } }) => (
<FormControl label="Signing Algorithm">
<Select onValueChange={onChange} value={value} className="w-full">
{allowedSigningAlgorithms.map((a) => (
<SelectItem key={a} value={a}>
{a.replaceAll("_", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="isBase64Encoded"
render={({ field: { onChange, value } }) => (
<Switch id="encode-base-64" isChecked={value} onCheckedChange={onChange}>
Data is Base64 encoded{" "}
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</Switch>
)}
/>
</div>
</>
)}
<div className="flex items-center">
<Button
className={`mr-4 ${signature ? "w-44" : ""}`}
size="sm"
leftIcon={
// eslint-disable-next-line no-nested-ternary
signature ? (
isCopyingSignature ? (
<FontAwesomeIcon icon={faCheckCircle} />
) : (
<FontAwesomeIcon icon={faFileSignature} />
)
) : (
<FontAwesomeIcon icon={faFileSignature} />
)
}
onClick={signature ? handleCopyToClipboard : undefined}
type={signature ? "button" : "submit"}
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{signature ? copySignature : "Sign"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
{signature ? "Close" : "Cancel"}
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekSignModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Sign Data"
subTitle={
<>
Sign data using <span className="font-bold">{cmek?.name}</span>. Returns a Base64
encoded signature.
</>
}
>
<SignForm cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -8,6 +8,7 @@ import {
faCopy,
faEdit,
faEllipsis,
faFileSignature,
faInfoCircle,
faKey,
faLock,
@@ -51,14 +52,17 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { kmsKeyUsageOptions } from "@app/helpers/kms";
import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks";
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
import { CmekOrderBy, TCmek } from "@app/hooks/api/cmeks/types";
import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { CmekDecryptModal } from "./CmekDecryptModal";
import { CmekEncryptModal } from "./CmekEncryptModal";
import { CmekModal } from "./CmekModal";
import { CmekSignModal } from "./CmekSignModal";
import { CmekVerifyModal } from "./CmekVerifyModal";
import { DeleteCmekModal } from "./DeleteCmekModal";
const getStatusBadgeProps = (
@@ -123,7 +127,9 @@ export const CmekTable = () => {
"upsertKey",
"deleteKey",
"encryptData",
"decryptData"
"decryptData",
"signData",
"verifyData"
] as const);
const handleSort = () => {
@@ -179,6 +185,15 @@ export const CmekTable = () => {
ProjectPermissionSub.Cmek
);
const cannotSignData = permission.cannot(
ProjectPermissionCmekActions.Sign,
ProjectPermissionSub.Cmek
);
const cannotVerifyData = permission.cannot(
ProjectPermissionCmekActions.Verify,
ProjectPermissionSub.Cmek
);
return (
<motion.div
key="kms-keys-tab"
@@ -246,6 +261,7 @@ export const CmekTable = () => {
</div>
</Th>
<Th>Key ID</Th>
<Th>Key Usage</Th>
<Th>Algorithm</Th>
<Th>Status</Th>
<Th>Version</Th>
@@ -257,7 +273,15 @@ export const CmekTable = () => {
{!isPending &&
keys.length > 0 &&
keys.map((cmek) => {
const { name, id, version, description, encryptionAlgorithm, isDisabled } = cmek;
const {
name,
id,
version,
description,
encryptionAlgorithm,
isDisabled,
keyUsage
} = cmek;
const { variant, label } = getStatusBadgeProps(isDisabled);
return (
@@ -295,6 +319,14 @@ export const CmekTable = () => {
</IconButton>
</div>
</Td>
<Td>
<div className="flex items-center gap-2">
{kmsKeyUsageOptions[keyUsage].label}
<Tooltip content={kmsKeyUsageOptions[keyUsage].tooltip}>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</div>
</Td>
<Td className="uppercase">{encryptionAlgorithm}</Td>
<Td>
<Badge variant={variant}>{label}</Badge>
@@ -314,50 +346,104 @@ export const CmekTable = () => {
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[160px]">
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotEncryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("encryptData", cmek)}
icon={<FontAwesomeIcon icon={faLock} />}
iconPos="left"
isDisabled={cannotEncryptData || isDisabled}
{keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && (
<>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotEncryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
Encrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotDecryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("decryptData", cmek)}
icon={<FontAwesomeIcon icon={faLockOpen} />}
iconPos="left"
isDisabled={cannotDecryptData || isDisabled}
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("encryptData", cmek)}
icon={<FontAwesomeIcon icon={faLock} />}
iconPos="left"
isDisabled={cannotEncryptData || isDisabled}
>
Encrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotDecryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
Decrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("decryptData", cmek)}
icon={<FontAwesomeIcon icon={faLockOpen} />}
iconPos="left"
isDisabled={cannotDecryptData || isDisabled}
>
Decrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
</>
)}
{keyUsage === KmsKeyUsage.SIGN_VERIFY && (
<>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotSignData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("signData", cmek)}
icon={<FontAwesomeIcon icon={faFileSignature} />}
iconPos="left"
isDisabled={cannotSignData || isDisabled}
>
Sign Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotVerifyData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("verifyData", cmek)}
icon={<FontAwesomeIcon icon={faCheckCircle} />}
iconPos="left"
isDisabled={cannotVerifyData || isDisabled}
>
Verify Data
</DropdownMenuItem>
</div>
</Tooltip>
</>
)}
<Tooltip
content={cannotEditKey ? "Access Restricted" : ""}
position="left"
@@ -456,6 +542,16 @@ export const CmekTable = () => {
onOpenChange={(isOpen) => handlePopUpToggle("decryptData", isOpen)}
cmek={popUp.decryptData.data as TCmek}
/>
<CmekSignModal
isOpen={popUp.signData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("signData", isOpen)}
cmek={popUp.signData.data as TCmek}
/>
<CmekVerifyModal
isOpen={popUp.verifyData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("verifyData", isOpen)}
cmek={popUp.verifyData.data as TCmek}
/>
</div>
</motion.div>
);
@@ -0,0 +1,251 @@
import { Controller, useForm } from "react-hook-form";
import { faFileSignature, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { decodeBase64 } from "tweetnacl-util";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Badge,
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { SigningAlgorithm, TCmek, useCmekVerify } from "@app/hooks/api/cmeks";
import { isBase64 } from "@app/lib/fn/base64";
const formSchema = z.object({
data: z.string().min(1, { message: "Data cannot be empty" }),
signature: z
.string()
.min(1, { message: "Signature cannot be empty" })
.superRefine((val, ctx) => {
if (!isBase64(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Signature must be base64-encoded"
});
}
}),
signingAlgorithm: z.nativeEnum(SigningAlgorithm),
isBase64Encoded: z.boolean()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek: TCmek;
};
type FormProps = Pick<Props, "cmek">;
const VerifyForm = ({ cmek }: FormProps) => {
const cmekVerify = useCmekVerify();
const {
handleSubmit,
register,
watch,
control,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
signingAlgorithm: cmek?.encryptionAlgorithm?.startsWith("RSA")
? SigningAlgorithm.RSASSA_PSS_SHA_512
: SigningAlgorithm.ECDSA_SHA_256,
isBase64Encoded: false
}
});
const handleVerifyData = async (formData: FormData) => {
try {
const result = await cmekVerify.mutateAsync({ ...formData, keyId: cmek.id });
if (result.signatureValid) {
createNotification({
text: "Successfully verified signature",
type: "success"
});
} else {
createNotification({
title: "Signature Verification Failed",
text: "The signature is invalid. The signature was not created using the same signing algorithm and key as the one used to sign the data. The data and signature may have been tampered with.",
type: "error"
});
}
} catch (err) {
console.error(err);
createNotification({
text: "Failed to sign data",
type: "error"
});
}
};
const signature = watch("signature");
const data = watch("data");
const isBase64Encoded = watch("isBase64Encoded");
const signatureValid = cmekVerify.data?.signatureValid;
const signingAlgorithm = cmekVerify.data?.signingAlgorithm;
const allowedSigningAlgorithms = Object.values(SigningAlgorithm).filter((a) =>
cmek?.encryptionAlgorithm?.startsWith("RSA")
? a.toLowerCase().startsWith("rsa")
: a.toLowerCase().startsWith("ecdsa")
);
return (
<form onSubmit={handleSubmit(handleVerifyData)}>
{signatureValid !== undefined ? (
<div className="mb-6 flex flex-col gap-2">
<div className="flex items-center justify-between space-x-2">
<span className="text-sm opacity-60">Signature Status:</span>
<Badge variant={signatureValid ? "success" : "danger"}>
<Tooltip
content={
signatureValid
? "The signature is valid. signature was created using the same signing algorithm and key as the one used to sign the data."
: "The signature is invalid. The signature was not created using the same signing algorithm and key as the one used to sign the data. The data and signature may have been tampered with."
}
>
{signatureValid ? (
<div className="flex items-center justify-center gap-2">
<p>Valid</p>
</div>
) : (
<div className="flex items-center justify-center gap-2">
<p>Invalid</p>
</div>
)}
</Tooltip>
</Badge>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-sm opacity-60">Signing Algorithm:</span>
<Badge variant="primary">{signingAlgorithm}</Badge>
</div>
<div className="mt-3">
<span className="text-sm opacity-60">Signature:</span>{" "}
<div className="whitespace-pre-wrap break-words rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 text-sm">
{signature}
</div>
</div>
<div>
<span className="text-sm opacity-60">Data:</span>{" "}
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 text-sm">
{isBase64Encoded ? decodeBase64(data).toString() : data}
</div>
</div>
</div>
) : (
<>
<FormControl
label="Data to Verify"
errorText={errors.data?.message}
isError={Boolean(errors.data)}
>
<TextArea
{...register("data")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<FormControl
label="Signature of Data"
tooltipText="Must be base64-encoded, like the signature you received when you signed the data."
errorText={errors.signature?.message}
isError={Boolean(errors.signature)}
>
<TextArea
{...register("signature")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<div className="mb-6 flex w-full items-center justify-between gap-2">
<Controller
control={control}
name="signingAlgorithm"
render={({ field: { onChange, value } }) => (
<FormControl label="Signing Algorithm">
<Select onValueChange={onChange} value={value} className="w-full">
{allowedSigningAlgorithms.map((a) => (
<SelectItem key={a} value={a}>
{a.replaceAll("_", " ")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="isBase64Encoded"
render={({ field: { onChange, value } }) => (
<Switch id="encode-base-64" isChecked={value} onCheckedChange={onChange}>
Data is Base64 encoded{" "}
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</Switch>
)}
/>
</div>
</>
)}
<div className="flex items-center">
{signatureValid === undefined && (
<Button
className="mr-4 w-44"
size="sm"
leftIcon={<FontAwesomeIcon icon={faFileSignature} />}
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Verify
</Button>
)}
<ModalClose asChild>
<Button
colorSchema={signatureValid === undefined ? "secondary" : "primary"}
variant={signatureValid === undefined ? "plain" : undefined}
>
{signatureValid !== undefined ? "Close" : "Cancel"}
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekVerifyModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Verify Signature"
subTitle={
<>
Verify a signature using <span className="font-bold">{cmek?.name}</span>.
</>
}
>
<VerifyForm cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -20,6 +20,7 @@ import { GitHubConnectionForm } from "./GitHubConnectionForm";
import { HumanitecConnectionForm } from "./HumanitecConnectionForm";
import { MsSqlConnectionForm } from "./MsSqlConnectionForm";
import { PostgresConnectionForm } from "./PostgresConnectionForm";
import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm";
import { VercelConnectionForm } from "./VercelConnectionForm";
type FormProps = {
@@ -73,6 +74,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => {
return <DatabricksConnectionForm onSubmit={onSubmit} />;
case AppConnection.Humanitec:
return <HumanitecConnectionForm onSubmit={onSubmit} />;
case AppConnection.TerraformCloud:
return <TerraformCloudConnectionForm onSubmit={onSubmit} />;
case AppConnection.Vercel:
return <VercelConnectionForm onSubmit={onSubmit} />;
case AppConnection.Postgres:
@@ -133,6 +136,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => {
return <DatabricksConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Humanitec:
return <HumanitecConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.TerraformCloud:
return <TerraformCloudConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Vercel:
return <VercelConnectionForm onSubmit={onSubmit} appConnection={appConnection} />;
case AppConnection.Postgres:
@@ -0,0 +1,135 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Button,
FormControl,
ModalClose,
SecretInput,
Select,
SelectItem
} from "@app/components/v2";
import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections";
import {
TerraformCloudConnectionMethod,
TTerraformCloudConnection
} from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
genericAppConnectionFieldsSchema,
GenericAppConnectionsFields
} from "./GenericAppConnectionFields";
type Props = {
appConnection?: TTerraformCloudConnection;
onSubmit: (formData: FormData) => void;
};
const rootSchema = genericAppConnectionFieldsSchema.extend({
app: z.literal(AppConnection.TerraformCloud)
});
const formSchema = z.discriminatedUnion("method", [
rootSchema.extend({
method: z.literal(TerraformCloudConnectionMethod.ApiToken),
credentials: z.object({
apiToken: z.string().trim().min(1, "API Token required")
})
})
]);
type FormData = z.infer<typeof formSchema>;
export const TerraformCloudConnectionForm = ({ appConnection, onSubmit }: Props) => {
const isUpdate = Boolean(appConnection);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: appConnection ?? {
app: AppConnection.TerraformCloud,
method: TerraformCloudConnectionMethod.ApiToken
}
});
const {
handleSubmit,
control,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
{!isUpdate && <GenericAppConnectionsFields />}
<Controller
name="method"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`The method you would like to use to connect with ${
APP_CONNECTION_MAP[AppConnection.TerraformCloud].name
}. This field cannot be changed after creation.`}
errorText={error?.message}
isError={Boolean(error?.message)}
label="Method"
>
<Select
isDisabled={isUpdate}
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
position="popper"
dropdownContainerClassName="max-w-none"
>
{Object.values(TerraformCloudConnectionMethod).map((method) => {
return (
<SelectItem value={method} key={method}>
{getAppConnectionMethodDetails(method).name}{" "}
</SelectItem>
);
})}
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.apiToken"
control={control}
shouldUnregister
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="API Token"
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Credentials" : "Connect to Terraform Cloud"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,189 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
position?: number;
isDisabled?: boolean;
};
export const DynamicSecretPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const {
control,
watch,
setValue,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions`
});
const conditionErrorMessage =
errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.message ||
errors?.permissions?.[ProjectPermissionSub.DynamicSecrets]?.[position]?.conditions?.root
?.message;
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="text-sm text-mineshaft-400">
Conditions determine when a policy will be applied (always if no conditions are present).
</p>
<p className="mb-3 text-sm leading-4 text-mineshaft-400">
All conditions must evaluate to true for the policy to take effect.
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition = watch(
`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}`
) as {
lhs: string;
rhs: string;
operator: string;
};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
setValue(
`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.operator`,
PermissionConditionOperators.$IN as never
);
field.onChange(e);
}}
className="w-full"
>
<SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem>
<SelectItem value="metadataKey">Metadata Key</SelectItem>
<SelectItem value="metadataValue">Metadata Value</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{renderOperatorSelectItems(condition.lhs)}
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${ProjectPermissionSub.DynamicSecrets}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{conditionErrorMessage && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{conditionErrorMessage}</span>
</div>
)}
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "environment",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -47,7 +47,9 @@ const CmekPolicyActionSchema = z.object({
delete: z.boolean().optional(),
create: z.boolean().optional(),
encrypt: z.boolean().optional(),
decrypt: z.boolean().optional()
decrypt: z.boolean().optional(),
sign: z.boolean().optional(),
verify: z.boolean().optional()
});
const DynamicSecretPolicyActionSchema = z.object({
@@ -283,11 +285,59 @@ const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition
} else {
Object.keys(condition).forEach((conditionOperator) => {
const rhs = condition[conditionOperator as PermissionConditionOperators];
formConditions.push({
operator: conditionOperator,
lhs: type,
rhs: typeof rhs === "string" ? rhs : rhs.join(",")
});
if (Array.isArray(rhs) || typeof rhs === "string") {
formConditions.push({
operator: conditionOperator,
lhs: type,
rhs: typeof rhs === "string" ? rhs : rhs.join(",")
});
} else if (
conditionOperator === PermissionConditionOperators.$ELEMENTMATCH &&
type === "metadata"
) {
const deepKeyCondition = rhs.key;
if (deepKeyCondition) {
if (typeof deepKeyCondition === "string") {
formConditions.push({
operator: PermissionConditionOperators.$EQ,
lhs: "metadataKey",
rhs: deepKeyCondition
});
} else {
Object.keys(deepKeyCondition).forEach((keyOperator) => {
const deepRhs = deepKeyCondition?.[keyOperator as PermissionConditionOperators];
if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) {
formConditions.push({
operator: keyOperator,
lhs: "metadataKey",
rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",")
});
}
});
}
}
const deepValueCondition = rhs.value;
if (deepValueCondition) {
if (typeof deepValueCondition === "string") {
formConditions.push({
operator: PermissionConditionOperators.$EQ,
lhs: "metadataValue",
rhs: deepValueCondition
});
} else {
Object.keys(deepValueCondition).forEach((keyOperator) => {
const deepRhs = deepValueCondition?.[keyOperator as PermissionConditionOperators];
if (deepRhs && (Array.isArray(deepRhs) || typeof deepRhs === "string")) {
formConditions.push({
operator: keyOperator,
lhs: "metadataValue",
rhs: typeof deepRhs === "string" ? deepRhs : deepRhs.join(",")
});
}
});
}
}
}
});
}
});
@@ -482,6 +532,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const canCreate = action.includes(ProjectPermissionCmekActions.Create);
const canEncrypt = action.includes(ProjectPermissionCmekActions.Encrypt);
const canDecrypt = action.includes(ProjectPermissionCmekActions.Decrypt);
const canSign = action.includes(ProjectPermissionCmekActions.Sign);
const canVerify = action.includes(ProjectPermissionCmekActions.Verify);
if (!formVal[subject]) formVal[subject] = [{}];
@@ -492,6 +544,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
if (canDelete) formVal[subject]![0].delete = true;
if (canEncrypt) formVal[subject]![0].encrypt = true;
if (canDecrypt) formVal[subject]![0].decrypt = true;
if (canSign) formVal[subject]![0].sign = true;
if (canVerify) formVal[subject]![0].verify = true;
return;
}
@@ -630,7 +684,45 @@ const convertFormOperatorToCaslCondition = (
conditions: { lhs: string; rhs: string; operator: string }[]
) => {
const caslCondition: Record<string, Partial<TPermissionConditionOperators>> = {};
const metadataKeyCondition = conditions.find((condition) => condition.lhs === "metadataKey");
const metadataValueCondition = conditions.find((condition) => condition.lhs === "metadataValue");
if (metadataKeyCondition || metadataValueCondition) {
caslCondition.metadata = {
[PermissionConditionOperators.$ELEMENTMATCH]: {}
};
if (metadataKeyCondition) {
const operator = metadataKeyCondition.operator as PermissionConditionOperators;
caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.key = {
[metadataKeyCondition.operator]: [
PermissionConditionOperators.$IN,
PermissionConditionOperators.$ALL
].includes(operator)
? metadataKeyCondition.rhs.split(",")
: metadataKeyCondition.rhs
};
}
if (metadataValueCondition) {
const operator = metadataValueCondition.operator as PermissionConditionOperators;
caslCondition.metadata[PermissionConditionOperators.$ELEMENTMATCH]!.value = {
[metadataValueCondition.operator]: [
PermissionConditionOperators.$IN,
PermissionConditionOperators.$ALL
].includes(operator)
? metadataValueCondition.rhs.split(",")
: metadataValueCondition.rhs
};
}
}
conditions.forEach((el) => {
// these are special fields and handled above
if (el.lhs === "metadataKey" || el.lhs === "metadataValue") {
return;
}
if (!caslCondition[el.lhs]) caslCondition[el.lhs] = {};
if (
el.operator === PermissionConditionOperators.$IN ||
@@ -641,7 +733,9 @@ const convertFormOperatorToCaslCondition = (
caslCondition[el.lhs][
el.operator as Exclude<
PermissionConditionOperators,
PermissionConditionOperators.$ALL | PermissionConditionOperators.$IN
| PermissionConditionOperators.$ALL
| PermissionConditionOperators.$IN
| PermissionConditionOperators.$ELEMENTMATCH
>
] = el.rhs;
}
@@ -769,7 +863,9 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" },
{ label: "Encrypt", value: "encrypt" },
{ label: "Decrypt", value: "decrypt" }
{ label: "Decrypt", value: "decrypt" },
{ label: "Sign", value: "sign" },
{ label: "Verify", value: "verify" }
]
},
[ProjectPermissionSub.Kms]: {
@@ -21,6 +21,7 @@ import { evaluatePermissionsAbility } from "@app/helpers/permissions";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { DynamicSecretPermissionConditions } from "./DynamicSecretPermissionConditions";
import { GeneralPermissionConditions } from "./GeneralPermissionConditions";
import { GeneralPermissionPolicies } from "./GeneralPermissionPolicies";
import { IdentityManagementPermissionConditions } from "./IdentityManagementPermissionConditions";
@@ -48,6 +49,9 @@ export const renderConditionalComponents = (
if (subject === ProjectPermissionSub.Secrets)
return <SecretPermissionConditions isDisabled={isDisabled} />;
if (subject === ProjectPermissionSub.DynamicSecrets)
return <DynamicSecretPermissionConditions isDisabled={isDisabled} />;
if (isConditionalSubjects(subject)) {
if (subject === ProjectPermissionSub.Identity) {
return <IdentityManagementPermissionConditions isDisabled={isDisabled} />;
@@ -5,7 +5,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Badge, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { Badge, PageHeader, Tab, TabList, TabPanel, Tabs, Tooltip } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
@@ -51,46 +51,19 @@ export const IntegrationsListPage = () => {
title="Integrations"
description="Manage integrations with third-party services."
/>
<div className="mb-4 mt-4 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
Integrations Update
</div>
<p className="mb-2 mt-1 text-sm text-bunker-300">
Infisical is excited to announce{" "}
<a
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/integrations/secret-syncs/overview"
target="_blank"
rel="noopener noreferrer"
>
Secret Syncs
</a>
, a new way to sync your secrets to third-party services using{" "}
<a
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/integrations/app-connections/overview"
target="_blank"
rel="noopener noreferrer"
>
App Connections
</a>
, offering deeper customization and re-configurability.
</p>
<p className="text-sm text-bunker-300">
Existing integrations (now called Native Integrations) will continue to be supported
as we build out our Secret Sync library.
</p>
</div>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={IntegrationsListPageTabs.SecretSyncs}>
Secret Syncs
<Badge variant="primary" className="ml-1 cursor-pointer text-xs">
New
</Badge>
<Tab value={IntegrationsListPageTabs.SecretSyncs}>Secret Syncs</Tab>
<Tab value={IntegrationsListPageTabs.NativeIntegrations}>
Native Integrations
<Tooltip content="Native Integrations will be deprecated in 2026. Please migrate to Secret Syncs as they become available.">
<div>
<Badge variant="primary" className="ml-1 cursor-pointer text-xs">
Legacy
</Badge>
</div>
</Tooltip>
</Tab>
<Tab value={IntegrationsListPageTabs.NativeIntegrations}>Native Integrations</Tab>
<Tab value={IntegrationsListPageTabs.FrameworkIntegrations}>
Framework Integrations
</Tab>
@@ -108,6 +81,26 @@ export const IntegrationsListPage = () => {
</ProjectPermissionCan>
</TabPanel>
<TabPanel value={IntegrationsListPageTabs.NativeIntegrations}>
<div className="mb-5 flex flex-col rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5">
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
Native Integrations Transitioning to Legacy Status
</div>
<p className="mb-2 mt-1 text-sm text-bunker-300">
Native integrations are now a legacy feature and we will begin a phased
deprecation in 2026. We recommend migrating to our new{" "}
<a
className="text-bunker-200 underline decoration-primary-700 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/integrations/secret-syncs/overview"
target="_blank"
rel="noopener noreferrer"
>
Secret Syncs
</a>{" "}
feature which offers the same functionality as Native Integrations with improved
stability, insights, re-configurability, and customization.
</p>
</div>
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionActions.Read}
@@ -8,6 +8,7 @@ import {
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner";
import { createNotification } from "@app/components/notifications";
@@ -19,6 +20,7 @@ import {
Skeleton,
Tooltip
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
ProjectPermissionActions,
ProjectPermissionSub,
@@ -26,7 +28,9 @@ import {
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { IntegrationAuth, TCloudIntegration } from "@app/hooks/api/types";
import { IntegrationsListPageTabs } from "@app/types/integrations";
type Props = {
isLoading?: boolean;
@@ -40,6 +44,9 @@ type Props = {
type TRevokeIntegrationPopUp = { provider: string };
const SECRET_SYNCS = Object.values(SecretSync) as string[];
const isSecretSyncAvailable = (type: string) => SECRET_SYNCS.includes(type);
export const CloudIntegrationSection = ({
isLoading,
cloudIntegrations = [],
@@ -54,6 +61,7 @@ export const CloudIntegrationSection = ({
] as const);
const { permission } = useProjectPermission();
const { currentWorkspace } = useWorkspace();
const navigate = useNavigate();
const isEmpty = !isLoading && !cloudIntegrations?.length;
@@ -111,73 +119,104 @@ export const CloudIntegrationSection = ({
))}
{!isLoading && filteredIntegrations.length ? (
filteredIntegrations.map((cloudIntegration) => (
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
className={`group relative ${
cloudIntegration.isAvailable
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
: "opacity-50"
} flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
onClick={() => {
if (!cloudIntegration.isAvailable) return;
if (
permission.cannot(
ProjectPermissionActions.Create,
ProjectPermissionSub.Integrations
)
) {
createNotification({
type: "error",
text: "You do not have permission to create an integration"
});
return;
}
onIntegrationStart(cloudIntegration.slug);
}}
key={cloudIntegration.slug}
>
<img
src={`/images/integrations/${cloudIntegration.image}`}
height={60}
width={60}
className="mt-auto"
alt="integration logo"
/>
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{cloudIntegration.name}
</div>
{cloudIntegration.isAvailable &&
Boolean(integrationAuths?.[cloudIntegration.slug]) && (
<div className="absolute right-0 top-0 z-30 h-full">
<div className="relative h-full">
<div className="absolute right-0 top-0 w-24 flex-row items-center overflow-hidden whitespace-nowrap rounded-bl-md rounded-tr-md bg-primary px-2 py-0.5 text-xs text-black opacity-80 transition-all duration-300 group-hover:w-0 group-hover:p-0">
<FontAwesomeIcon icon={faCheck} className="mr-2 text-xs" />
Authorized
</div>
<Tooltip content="Revoke Access">
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
onClick={async (event) => {
event.stopPropagation();
handlePopUpOpen("deleteConfirmation", {
provider: cloudIntegration.slug
});
}}
className="absolute right-0 top-0 flex h-0 w-12 cursor-pointer items-center justify-center overflow-hidden rounded-r-md bg-red text-xs opacity-50 transition-all duration-300 hover:opacity-100 group-hover:h-full"
>
<FontAwesomeIcon icon={faXmark} size="xl" />
filteredIntegrations.map((cloudIntegration) => {
const syncSlug = cloudIntegration.syncSlug ?? cloudIntegration.slug;
const isSyncAvailable = isSecretSyncAvailable(syncSlug);
return (
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
className={`group relative ${
cloudIntegration.isAvailable
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
: "opacity-50"
} flex h-36 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3`}
onClick={() => {
if (isSyncAvailable) {
navigate({
to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path,
params: {
projectId: currentWorkspace.id
},
search: {
selectedTab: IntegrationsListPageTabs.SecretSyncs,
addSync: syncSlug as SecretSync
}
});
return;
}
if (!cloudIntegration.isAvailable) return;
if (
permission.cannot(
ProjectPermissionActions.Create,
ProjectPermissionSub.Integrations
)
) {
createNotification({
type: "error",
text: "You do not have permission to create an integration"
});
return;
}
onIntegrationStart(cloudIntegration.slug);
}}
key={cloudIntegration.slug}
>
<div className="m-auto flex flex-col items-center">
<img
src={`/images/integrations/${cloudIntegration.image}`}
height={60}
width={60}
className="mt-auto"
alt="integration logo"
/>
<div
className={`mt-2 max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200 ${isSyncAvailable ? "mb-4" : ""}`}
>
{cloudIntegration.name}
</div>
</div>
{cloudIntegration.isAvailable &&
Boolean(integrationAuths?.[cloudIntegration.slug]) && (
<div className="absolute right-0 top-0 z-30 h-full">
<div className="relative h-full">
<div className="absolute right-0 top-0 w-24 flex-row items-center overflow-hidden whitespace-nowrap rounded-bl-md rounded-tr-md bg-primary px-2 py-0.5 text-xs text-black opacity-80 transition-all duration-300 group-hover:w-0 group-hover:p-0">
<FontAwesomeIcon icon={faCheck} className="mr-2 text-xs" />
Authorized
</div>
</Tooltip>
<Tooltip content="Revoke Access">
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
onClick={async (event) => {
event.stopPropagation();
handlePopUpOpen("deleteConfirmation", {
provider: cloudIntegration.slug
});
}}
className="absolute right-0 top-0 flex h-0 w-12 cursor-pointer items-center justify-center overflow-hidden rounded-r-md bg-red text-xs opacity-50 transition-all duration-300 hover:opacity-100 group-hover:h-full"
>
<FontAwesomeIcon icon={faXmark} size="xl" />
</div>
</Tooltip>
</div>
</div>
)}
{isSyncAvailable && (
<div className="absolute bottom-0 left-0 z-30 h-full w-full">
<div className="relative h-full">
<div className="absolute bottom-0 left-0 w-full flex-row overflow-hidden whitespace-nowrap rounded-bl-md rounded-br-md bg-yellow/20 px-2 py-0.5 text-center text-xs text-yellow">
Secret Sync Available
</div>
</div>
</div>
)}
</div>
))
</div>
);
})
) : (
<EmptyState
className="col-span-full h-32 w-full rounded-md bg-transparent pt-14"
@@ -9,6 +9,7 @@ import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol";
import { GcpSyncDestinationCol } from "./GcpSyncDestinationCol";
import { GitHubSyncDestinationCol } from "./GitHubSyncDestinationCol";
import { HumanitecSyncDestinationCol } from "./HumanitecSyncDestinationCol";
import { TerraformCloudSyncDestinationCol } from "./TerraformCloudSyncDestinationCol";
import { VercelSyncDestinationCol } from "./VercelSyncDestinationCol";
type Props = {
@@ -33,6 +34,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <DatabricksSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Humanitec:
return <HumanitecSyncDestinationCol secretSync={secretSync} />;
case SecretSync.TerraformCloud:
return <TerraformCloudSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Camunda:
return <CamundaSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Vercel:
@@ -0,0 +1,14 @@
import { TTerraformCloudSync } from "@app/hooks/api/secretSyncs/types/terraform-cloud-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TTerraformCloudSync;
};
export const TerraformCloudSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -1,3 +1,4 @@
import { TerraformCloudSyncScope } from "@app/hooks/api/appConnections/terraform-cloud";
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import {
GitHubSyncScope,
@@ -73,6 +74,14 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
}
secondaryText = `Organization - ${destinationConfig.org}`;
break;
case SecretSync.TerraformCloud:
primaryText = destinationConfig.org;
if (destinationConfig.scope === TerraformCloudSyncScope.VariableSet) {
secondaryText = destinationConfig.variableSetName;
} else {
secondaryText = destinationConfig.workspaceName;
}
break;
case SecretSync.Camunda:
primaryText = destinationConfig.clusterName ?? destinationConfig.clusterUUID;
secondaryText = "Cluster";
@@ -1,9 +1,12 @@
import { useEffect } from "react";
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { ProjectPermissionCan } from "@app/components/permissions";
import { CreateSecretSyncModal } from "@app/components/secret-syncs";
import { Button, Spinner } from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
@@ -14,8 +17,27 @@ import { SecretSyncsTable } from "./SecretSyncTable";
export const SecretSyncsTab = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const);
const { addSync, ...search } = useSearch({
from: ROUTE_PATHS.SecretManager.IntegrationsListPage.id
});
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
useEffect(() => {
if (!addSync) return;
handlePopUpOpen("addSync", addSync);
navigate({
to: ROUTE_PATHS.SecretManager.IntegrationsListPage.path,
params: {
projectId: currentWorkspace.id
},
search
});
}, [addSync]);
const { data: secretSyncs = [], isPending: isSecretSyncsPending } = useListSecretSyncs(
currentWorkspace.id,
{
@@ -76,6 +98,7 @@ export const SecretSyncsTab = () => {
<SecretSyncsTable secretSyncs={secretSyncs} />
</div>
<CreateSecretSyncModal
selectSync={popUp.addSync.data}
isOpen={popUp.addSync.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)}
/>
@@ -6,6 +6,7 @@ import { workspaceKeys } from "@app/hooks/api";
import { TIntegration } from "@app/hooks/api/integrations/types";
import {
fetchSecretSyncsByProjectId,
SecretSync,
secretSyncKeys,
TSecretSync
} from "@app/hooks/api/secretSyncs";
@@ -15,7 +16,8 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { IntegrationsListPage } from "./IntegrationsListPage";
const IntegrationsListPageQuerySchema = z.object({
selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional()
selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional(),
addSync: z.nativeEnum(SecretSync).optional()
});
export const Route = createFileRoute(
@@ -207,7 +207,8 @@ export const OverviewPage = () => {
ProjectPermissionDynamicSecretActions.CreateRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, {
environment: env.slug,
secretPath
secretPath,
metadata: ["*"]
})
)
);
@@ -272,7 +273,8 @@ export const OverviewPage = () => {
totalUniqueSecretsInPage,
totalUniqueSecretImportsInPage,
totalUniqueDynamicSecretsInPage,
totalUniqueSecretRotationsInPage
totalUniqueSecretRotationsInPage,
importedByEnvs
} = overview ?? {};
const secretImportsShaped = secretImports
@@ -1042,6 +1044,7 @@ export const OverviewPage = () => {
secretPath={secretPath}
selectedEntries={selectedEntries}
resetSelectedEntries={resetSelectedEntries}
importedByEnvs={importedByEnvs}
/>
<div className="thin-scrollbar mt-4">
<TableContainer
@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { subject } from "@casl/ability";
import { faAnglesRight, faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -14,6 +15,7 @@ import {
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
import { ProjectSecretsImportedBy } from "@app/hooks/api/dashboard/types";
import {
SecretType,
SecretV3RawSanitized,
@@ -21,6 +23,7 @@ import {
TSecretFolder
} from "@app/hooks/api/types";
import { MoveSecretsModal } from "@app/pages/secret-manager/OverviewPage/components/SelectionPanel/components";
import { CollapsibleSecretImports } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/CollapsibleSecretImports";
export enum EntryType {
FOLDER = "folder",
@@ -34,9 +37,15 @@ type Props = {
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
};
importedByEnvs?: { environment: string; importedBy: ProjectSecretsImportedBy[] }[];
};
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
export const SelectionPanel = ({
secretPath,
resetSelectedEntries,
selectedEntries,
importedByEnvs
}: Props) => {
const { permission } = useProjectPermission();
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
@@ -72,6 +81,81 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
)
);
const secretsToDeleteKeys = useMemo(() => {
return Object.values(selectedEntries.secret).flatMap((entries) =>
Object.values(entries).map((secret) => secret.key)
);
}, [selectedEntries]);
const filterAndMergeEnvironments = (
envNames: string[],
envs: { environment: string; importedBy: ProjectSecretsImportedBy[] }[]
): ProjectSecretsImportedBy[] => {
const filteredEnvs = envs.filter((env) => envNames.includes(env.environment));
if (filteredEnvs.length === 0) return [];
const allImportedBy = filteredEnvs.flatMap((env) => env.importedBy);
const groupedBySlug: Record<string, ProjectSecretsImportedBy[]> = {};
allImportedBy.forEach((item) => {
const { slug } = item.environment;
if (!groupedBySlug[slug]) groupedBySlug[slug] = [];
groupedBySlug[slug].push(item);
});
const mergedImportedBy = Object.values(groupedBySlug).map((group) => {
const { environment } = group[0];
const allFolders = group.flatMap((item) => item.folders);
const foldersByName: Record<string, (typeof allFolders)[number][]> = {};
allFolders.forEach((folder) => {
if (!foldersByName[folder.name]) foldersByName[folder.name] = [];
foldersByName[folder.name].push(folder);
});
const mergedFolders = Object.entries(foldersByName).map(([name, folders]) => {
const isImported = folders.some((folder) => folder.isImported);
const allSecrets = folders.flatMap((folder) => folder.secrets || []);
const uniqueSecrets: { secretId: string; referencedSecretKey: string }[] = [];
const secretIds = new Set<string>();
allSecrets
.filter((secret) => secretsToDeleteKeys.includes(secret.referencedSecretKey))
.forEach((secret) => {
if (!secretIds.has(secret.secretId)) {
secretIds.add(secret.secretId);
uniqueSecrets.push(secret);
}
});
return {
name,
isImported,
...(uniqueSecrets.length > 0 ? { secrets: uniqueSecrets } : {})
};
});
return {
environment,
folders: mergedFolders.filter(
(folder) => folder.isImported || (folder.secrets && folder.secrets.length > 0)
)
};
});
return mergedImportedBy;
};
const importedBy = useMemo(() => {
if (selectedKeysCount === 0 || !importedByEnvs) return null;
return filterAndMergeEnvironments(
Object.values(selectedEntries.secret).flatMap((entries) => Object.keys(entries)),
importedByEnvs
);
}, [importedByEnvs, selectedEntries, selectedKeysCount]);
const getDeleteModalTitle = () => {
if (selectedFolderCount > 0 && selectedKeysCount > 0) {
return "Do you want to delete the selected secrets and folders across environments?";
@@ -241,6 +325,15 @@ export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntri
title={getDeleteModalTitle()}
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
onDeleteApproved={handleBulkDelete}
formContent={
importedBy &&
importedBy.some((element) => element.folders.length > 0) && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={secretsToDeleteKeys}
/>
)
}
/>
</>
);
@@ -138,7 +138,7 @@ const Page = () => {
const canReadDynamicSecret = permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath, metadata: ["*"] })
);
const canReadSecretRotations = permission.can(
@@ -219,6 +219,7 @@ const Page = () => {
totalDynamicSecretCount = 0,
totalSecretCount = 0,
totalCount = 0,
importedBy,
totalSecretRotationCount = 0
} = data ?? {};
@@ -439,6 +440,7 @@ const Page = () => {
onToggleRowType={handleToggleRowType}
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
protectedBranchPolicyName={boardPolicy?.name}
importedBy={importedBy}
/>
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
<div className="flex flex-col" id="dashboard">
@@ -527,9 +529,10 @@ const Page = () => {
workspaceId={workspaceId}
secretPath={secretPath}
isProtectedBranch={isProtectedBranch}
importedBy={importedBy}
/>
)}
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
{!canReadSecret &&
!canReadDynamicSecret &&
!canReadSecretImports &&
@@ -81,6 +81,7 @@ import {
useSelectedSecrets
} from "../../SecretMainPage.store";
import { Filter, RowType } from "../../SecretMainPage.types";
import { CollapsibleSecretImports } from "../SecretListView/CollapsibleSecretImports";
import { ReplicateFolderFromBoard } from "./ReplicateFolderFromBoard/ReplicateFolderFromBoard";
import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
import { CreateSecretImportForm } from "./CreateSecretImportForm";
@@ -112,6 +113,14 @@ type Props = {
onVisibilityToggle: () => void;
onToggleRowType: (rowType: RowType) => void;
onClickRollbackMode: () => void;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
isImported: boolean;
}[];
}[];
};
export const ActionBar = ({
@@ -129,7 +138,8 @@ export const ActionBar = ({
onVisibilityToggle,
onClickRollbackMode,
onToggleRowType,
protectedBranchPolicyName
protectedBranchPolicyName,
importedBy
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
@@ -854,7 +864,8 @@ export const ActionBar = ({
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
secretTags: ["*"],
metadata: ["*"]
})}
>
{(isAllowed) => (
@@ -1059,6 +1070,15 @@ export const ActionBar = ({
title="Do you want to delete these secrets?"
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
onDeleteApproved={handleSecretBulkDelete}
formContent={
importedBy &&
importedBy.length > 0 && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={Object.values(selectedSecrets).map((s) => s.key)}
/>
)
}
/>
<MoveSecretsModal
popUp={popUp}
@@ -25,6 +25,8 @@ import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { MetadataForm } from "../../DynamicSecretListView/MetadataForm";
const passwordRequirementsSchema = z
.object({
length: z.number().min(1).max(250),
@@ -82,8 +84,16 @@ const formSchema = z.object({
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
environment: z.object({ name: z.string(), slug: z.string() }),
metadata: z
.object({
key: z.string().trim().min(1),
value: z.string().trim().default("")
})
.array()
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
@@ -192,7 +202,8 @@ export const SqlDatabaseInputForm = ({
maxTTL,
provider,
defaultTTL,
environment
environment,
metadata
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
@@ -205,7 +216,8 @@ export const SqlDatabaseInputForm = ({
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment.slug
environmentSlug: environment.slug,
metadata
});
onCompleted();
} catch {
@@ -283,46 +295,47 @@ export const SqlDatabaseInputForm = ({
/>
</div>
</div>
<div>
<Controller
control={control}
name="provider.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<MetadataForm control={control} />
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div>
<Controller
control={control}
name="provider.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Select
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.projectGatewayId}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex flex-col">
<div className="pb-0.5 pl-1 text-sm text-mineshaft-400">Service</div>
<Controller
@@ -41,6 +41,7 @@ interface FolderProps {
isExpanded?: boolean;
level: number;
basePath?: string;
isDisabled?: boolean;
}
interface TreeViewProps {
@@ -48,6 +49,7 @@ interface TreeViewProps {
basePath?: string;
className?: string;
onChange: (items: SecretItem[]) => void;
isDisabled?: boolean;
}
const getAllItemsInFolder = (folder: FolderStructure): SecretItem[] => {
@@ -81,7 +83,8 @@ const Folder: React.FC<FolderProps> = ({
onFolderSelect,
isExpanded = false,
level,
basePath
basePath,
isDisabled = false
}) => {
const [open, setOpen] = useState(isExpanded);
const displayName = useMemo(() => getDisplayName(name), [name]);
@@ -127,13 +130,16 @@ const Folder: React.FC<FolderProps> = ({
className={`h-4 w-4 text-${level === 0 ? "mineshaft-300" : "yellow"}`}
/>
</div>
<Checkbox
id="folder-root"
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={allSelected || someSelected}
onCheckedChange={handleFolderSelect}
isIndeterminate={someSelected && !allSelected}
/>
{!isDisabled && (
<Checkbox
id="folder-root"
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={allSelected || someSelected}
onCheckedChange={handleFolderSelect}
isIndeterminate={someSelected && !allSelected}
isDisabled={isDisabled}
/>
)}
<label
htmlFor={`folder-${path}`}
@@ -158,12 +164,15 @@ const Folder: React.FC<FolderProps> = ({
<div className="ml-6 mr-2">
<FontAwesomeIcon icon={faKey} className="h-3 w-3" />
</div>
<Checkbox
id={`folder-${item.id}`}
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={selectedItemIds.includes(item.id)}
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
/>
{!isDisabled && (
<Checkbox
id={`folder-${item.id}`}
className="data-[state=indeterminate]:bg-secondary data-[state=checked]:bg-primary"
isChecked={selectedItemIds.includes(item.id)}
onCheckedChange={(checked) => onItemSelect(item, !!checked)}
isDisabled={isDisabled}
/>
)}
<label
htmlFor={item.id}
className="ml-2 flex-1 cursor-pointer truncate"
@@ -184,6 +193,7 @@ const Folder: React.FC<FolderProps> = ({
onItemSelect={onItemSelect}
onFolderSelect={onFolderSelect}
level={level + 1}
isDisabled={isDisabled}
/>
))}
</div>
@@ -197,7 +207,8 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
data,
basePath = "/",
className = "",
onChange
onChange,
isDisabled = false
}) => {
const [selectedItems, setSelectedItems] = useState<SecretItem[]>([]);
const rootPath = "/";
@@ -305,16 +316,19 @@ export const SecretTreeView: React.FC<TreeViewProps> = ({
onFolderSelect={handleFolderSelect}
isExpanded
level={0}
isDisabled={isDisabled}
/>
))
)}
</div>
<div className="flex justify-end pb-2 pr-2 pt-2">
<h3 className="flex items-center text-mineshaft-400">
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
</h3>
</div>
{!isDisabled && (
<div className="flex justify-end pb-2 pr-2 pt-2">
<h3 className="flex items-center text-mineshaft-400">
{selectedItems.length} Item{selectedItems.length === 1 ? "" : "s"} Selected
</h3>
</div>
)}
</div>
</div>
);
@@ -29,11 +29,13 @@ import {
import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
import { RenewDynamicSecretLease } from "./RenewDynamicSecretLease";
type Props = {
dynamicSecret: TDynamicSecret;
dynamicSecretName: string;
projectSlug: string;
environment: string;
@@ -48,7 +50,8 @@ export const DynamicSecretLease = ({
environment,
secretPath,
onClickNewLease,
onClose
onClose,
dynamicSecret
}: Props) => {
const { handlePopUpOpen, popUp, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteSecret",
@@ -140,7 +143,11 @@ export const DynamicSecretLease = ({
<div className="flex items-center space-x-4">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Renew"
>
@@ -159,7 +166,11 @@ export const DynamicSecretLease = ({
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Delete"
>
@@ -181,7 +192,8 @@ export const DynamicSecretLease = ({
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath
secretPath,
metadata: dynamicSecret.metadata
})}
renderTooltip
allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems."
@@ -215,7 +227,8 @@ export const DynamicSecretLease = ({
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath
secretPath,
metadata: dynamicSecret.metadata
})}
>
{(isAllowed) => (
@@ -144,7 +144,11 @@ export const DynamicSecretListView = ({
<div className="flex items-center space-x-2 px-4 py-2">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip
allowedLabel="Edit"
>
@@ -186,7 +190,11 @@ export const DynamicSecretListView = ({
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.EditRootCredential}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip
allowedLabel="Edit"
>
@@ -208,7 +216,11 @@ export const DynamicSecretListView = ({
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.DeleteRootCredential}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })}
a={subject(ProjectPermissionSub.DynamicSecrets, {
environment,
secretPath,
metadata: secret.metadata
})}
renderTooltip
allowedLabel="Delete"
>
@@ -236,6 +248,7 @@ export const DynamicSecretListView = ({
className="max-w-3xl"
>
<DynamicSecretLease
dynamicSecret={secret}
onClickNewLease={() => handlePopUpOpen("createDynamicSecretLease", secret)}
onClose={() => handlePopUpClose("dynamicSecretLeases")}
projectSlug={projectSlug}
@@ -23,6 +23,8 @@ import { useWorkspace } from "@app/context";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import { MetadataForm } from "../MetadataForm";
const passwordRequirementsSchema = z
.object({
length: z.number().min(1).max(250),
@@ -85,6 +87,13 @@ const formSchema = z.object({
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional(),
metadata: z
.object({
key: z.string().trim().min(1),
value: z.string().trim().default("")
})
.array()
.optional()
});
type TForm = z.infer<typeof formSchema>;
@@ -126,6 +135,7 @@ export const EditDynamicSecretSqlProviderForm = ({
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
metadata: dynamicSecret.metadata,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"]),
passwordRequirements:
@@ -147,7 +157,13 @@ export const EditDynamicSecretSqlProviderForm = ({
const isGatewayInActive =
projectGateways?.findIndex((el) => el.projectGatewayId === selectedProjectGatewayId) === -1;
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
const handleUpdateDynamicSecret = async ({
inputs,
maxTTL,
defaultTTL,
newName,
metadata
}: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isPending) return;
try {
@@ -163,7 +179,8 @@ export const EditDynamicSecretSqlProviderForm = ({
...inputs,
projectGatewayId: isGatewayInActive ? null : inputs.projectGatewayId
},
newName: newName === dynamicSecret.name ? undefined : newName
newName: newName === dynamicSecret.name ? undefined : newName,
metadata
}
});
onClose();
@@ -229,46 +246,50 @@ export const EditDynamicSecretSqlProviderForm = ({
/>
</div>
</div>
<div>
<Controller
control={control}
name="inputs.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedProjectGatewayId
? `Project Gateway ${selectedProjectGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
<Select
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet Gateway"
position="popper"
>
<SelectItem value={null as unknown as string} onClick={() => onChange(undefined)}>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<MetadataForm control={control} />
<div>
<div className="mb-4 border-b border-b-mineshaft-600 pb-2">Configuration</div>
<div>
<Controller
control={control}
name="inputs.projectGatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedProjectGatewayId
? `Project Gateway ${selectedProjectGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
<Select
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isProjectGatewaysLoading}
placeholder="Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{projectGateways?.map((el) => (
<SelectItem value={el.projectGatewayId} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
</div>
<div className="flex flex-col">
<Controller
control={control}
@@ -0,0 +1,76 @@
import { Control, Controller, useFieldArray } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, FormLabel, IconButton, Input } from "@app/components/v2";
export const MetadataForm = ({ control }: { control: Control<any> }) => {
const metadataFormFields = useFieldArray({
control,
name: "metadata"
});
return (
<FormControl label="Metadata">
<div className="flex flex-col space-y-2">
{metadataFormFields.fields.map(({ id: metadataFieldId }, i) => (
<div key={metadataFieldId} className="flex items-end space-x-2">
<div className="flex-grow">
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`metadata.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} className="max-h-8" />
</FormControl>
)}
/>
</div>
<div className="flex-grow">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
)}
<Controller
control={control}
name={`metadata.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input {...field} className="max-h-8" />
</FormControl>
)}
/>
</div>
<IconButton
ariaLabel="delete key"
className="bottom-0.5 max-h-8"
variant="outline_bg"
onClick={() => metadataFormFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div className={`${metadataFormFields.fields.length > 0 ? "pt-2" : ""}`}>
<IconButton
ariaLabel="Add Key"
variant="outline_bg"
size="xs"
className="rounded-md"
onClick={() => metadataFormFields.append({ key: "", value: "" })}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
</div>
</FormControl>
);
};
@@ -0,0 +1,190 @@
import React, { useMemo } from "react";
import { faFileImport, faKey, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Table, TBody, Td, Th, THead, Tr } from "@app/components/v2";
import { useWorkspace } from "@app/context";
enum ItemType {
Folder = "Folder",
Secret = "Secret"
}
interface FlatItem {
type: ItemType;
path: string;
secretKey?: string;
reference: string;
id: string;
environment: { name: string; slug: string };
}
interface CollapsibleSecretImportsProps {
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
isImported: boolean;
}[];
}[];
secretsToDelete: string[];
}
export const CollapsibleSecretImports: React.FC<CollapsibleSecretImportsProps> = ({
importedBy = [],
secretsToDelete
}) => {
const { currentWorkspace } = useWorkspace();
const truncatePath = (path: string, maxLength = 24): string => {
if (path.length <= maxLength) return path;
const endPortion = path.slice(-(maxLength - 3));
const slashIndex = endPortion.indexOf("/");
if (slashIndex !== -1) {
return `...${endPortion.slice(slashIndex)}`;
}
return `...${endPortion}`;
};
const handlePathClick = (item: FlatItem) => {
let pathToNavigate;
if (item.type === ItemType.Folder) {
pathToNavigate = item.path;
} else {
const lastSlashIndex = item.path.lastIndexOf("/");
pathToNavigate = lastSlashIndex > 0 ? item.path.substring(0, lastSlashIndex) : "/";
}
const encodedPath = encodeURIComponent(pathToNavigate);
window.open(
`/secret-manager/${currentWorkspace.id}/secrets/${item.environment.slug}?secretPath=${encodedPath}`,
"_blank"
);
};
const flattenedItems = useMemo(() => {
const items: FlatItem[] = [];
importedBy.forEach((env) => {
env.folders.forEach((folder) => {
if (folder.isImported) {
items.push({
type: ItemType.Folder,
path: folder.name,
id: `folder-${env.environment.name}-${folder.name}`,
reference: "Imported",
environment: env.environment
});
}
if (folder.secrets?.length) {
folder.secrets.forEach(({ secretId: secret, referencedSecretKey }) => {
const secretPath = folder.name === "/" ? `/${secret}` : `${folder.name}/${secret}`;
if (
secretsToDelete.includes(referencedSecretKey) &&
!items.some(
(item) => item.environment.name === env.environment.name && item.path === secretPath
)
) {
items.push({
type: ItemType.Secret,
path: secretPath,
secretKey: referencedSecretKey,
id: `secret-${env.environment.name}-${secretPath}`,
reference: "Referenced",
environment: env.environment
});
}
});
}
});
});
return items.sort((a, b) => {
const envCompare = a.environment.name.localeCompare(b.environment.name);
if (envCompare !== 0) return envCompare;
const aPath = a.path.startsWith("/") ? a.path : `/${a.path}`;
const bPath = b.path.startsWith("/") ? b.path : `/${b.path}`;
const aSegments = aPath.split("/").filter(Boolean).length;
const bSegments = bPath.split("/").filter(Boolean).length;
if (aSegments !== bSegments) {
return aSegments - bSegments;
}
return aPath.localeCompare(bPath);
});
}, [importedBy]);
const hasImportedItems = importedBy.some((element) => {
if (element.folders && element.folders.length > 0) {
return element.folders.some(
(folder) =>
folder.isImported ||
(folder.secrets &&
folder.secrets.length > 0 &&
folder.secrets.some((secret) => secretsToDelete.includes(secret.referencedSecretKey)))
);
}
return false;
});
if (!hasImportedItems) {
return null;
}
return (
<div className="mb-4 w-full">
<div className="mb-4 rounded-md border border-red-700/30 bg-red-900/20">
<div className="flex items-start gap-3 p-4">
<div className="mt-0.5 flex-shrink-0 text-red-500">
<FontAwesomeIcon icon={faWarning} className="h-5 w-5" aria-hidden="true" />
</div>
<div className="w-full">
<p className="text-sm font-semibold text-red-500">
The following resources will be affected by this change
</p>
</div>
</div>
</div>
<div className="max-h-64 overflow-y-auto rounded-md border border-mineshaft-700">
<Table>
<THead className="sticky -top-1 bg-bunker-800">
<Th className="px-4">Type</Th>
<Th className="px-4">Environment</Th>
<Th className="truncate px-4">Path</Th>
<Th className="px-4">Usage</Th>
</THead>
<TBody>
{flattenedItems.map((item) => (
<Tr
key={item.id}
onClick={() => handlePathClick(item)}
className="cursor-pointer hover:bg-mineshaft-700"
title={`Navigate to ${item.path}`}
>
<Td>
<FontAwesomeIcon
icon={item.type === ItemType.Secret ? faKey : faFileImport}
className={`h-4 w-4 ${item.type === ItemType.Secret ? "text-gray-400" : "text-green-700"}`}
aria-hidden="true"
/>
</Td>
<Td className="px-4">{item.environment.name}</Td>
<Td className="truncate px-4">{truncatePath(item.path)}</Td>
<Td className="px-4">{item.reference}</Td>
</Tr>
))}
</TBody>
</Table>
</div>
</div>
);
};
@@ -16,6 +16,7 @@ import { WsTag } from "@app/hooks/api/types";
import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal";
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
import { SecretDetailSidebar } from "./SecretDetailSidebar";
import { SecretItem } from "./SecretItem";
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
@@ -28,6 +29,14 @@ type Props = {
tags?: WsTag[];
isVisible?: boolean;
isProtectedBranch?: boolean;
importedBy?: {
environment: { name: string; slug: string };
folders: {
name: string;
secrets?: { secretId: string; referencedSecretKey: string }[];
isImported: boolean;
}[];
}[];
};
export const SecretListView = ({
@@ -37,7 +46,8 @@ export const SecretListView = ({
secretPath = "/",
tags: wsTags = [],
isVisible,
isProtectedBranch = false
isProtectedBranch = false,
importedBy
}: Props) => {
const queryClient = useQueryClient();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
@@ -357,6 +367,24 @@ export const SecretListView = ({
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
onDeleteApproved={handleSecretDelete}
buttonText="Delete Secret"
formContent={
importedBy &&
importedBy.length > 0 && (
<CollapsibleSecretImports
importedBy={importedBy}
secretsToDelete={[(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key || ""]}
/>
)
}
deletionMessage={
<>
Type the secret key{" "}
<span className="font-bold">
&quot;{(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key}&quot;
</span>{" "}
to perform this action
</>
}
/>
<SecretDetailSidebar
environment={environment}
@@ -38,7 +38,12 @@ import {
Tr
} from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { ProjectPermissionSub, useWorkspace } from "@app/context";
import {
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { ProjectPermissionSecretRotationActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
import {
@@ -47,20 +52,29 @@ import {
useGetSecretRotations,
useRestartSecretRotation
} from "@app/hooks/api";
import { TSecretRotationProviderTemplate } from "@app/hooks/api/secretRotation/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { CreateRotationForm } from "@app/pages/secret-manager/SecretRotationPage/components/CreateRotationForm";
const Page = () => {
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const navigate = useNavigate();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"createRotation",
"activeBot",
"deleteRotation",
"upgradePlan",
"secretRotationV2"
] as const);
const workspaceId = currentWorkspace?.id || "";
const canCreateRotation = permission.can(
ProjectPermissionSecretRotationActions.Create,
ProjectPermissionSub.SecretRotation
);
const { subscription } = useSubscription();
const { data: secretRotationProviders, isPending: isRotationProviderLoading } =
useGetSecretRotationProviders({ workspaceId });
@@ -119,6 +133,18 @@ const Page = () => {
}
};
const handleCreateRotation = (provider: TSecretRotationProviderTemplate) => {
if (subscription && !subscription?.secretRotation) {
handlePopUpOpen("upgradePlan");
return;
}
if (!canCreateRotation) {
createNotification({ type: "error", text: "Access permission denied!!" });
return;
}
handlePopUpOpen("createRotation", provider);
};
return (
<div className="container mx-auto w-full max-w-7xl bg-bunker-800 text-white">
<PageHeader
@@ -144,7 +170,7 @@ const Page = () => {
Infisical is revamping its Secret Rotation experience.
</p>
<p className="mt-2 text-sm text-bunker-200">
Secret Rotations can now be created from the{" "}
PostgreSQL and Microsoft SQL Server Rotations can now be created from the{" "}
<Link
className="text-mineshaft-100 underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
to={`/${ProjectType.SecretManager}/$projectId/overview` as const}
@@ -308,11 +334,20 @@ const Page = () => {
key={`infisical-rotation-provider-${provider.name}`}
tabIndex={0}
role="button"
onKeyDown={() => {
handlePopUpOpen("secretRotationV2", provider.title);
onKeyDown={(evt) => {
if (evt.key !== "Enter") return;
if (provider.isDeprecated) {
handlePopUpOpen("secretRotationV2", provider.title);
} else {
handleCreateRotation(provider);
}
}}
onClick={() => {
handlePopUpOpen("secretRotationV2", provider.title);
if (provider.isDeprecated) {
handlePopUpOpen("secretRotationV2", provider.title);
} else {
handleCreateRotation(provider);
}
}}
>
<img
@@ -344,6 +379,12 @@ const Page = () => {
</div>
</a>
</div>
<CreateRotationForm
isOpen={popUp.createRotation.isOpen}
workspaceId={workspaceId}
onToggle={(isOpen) => handlePopUpToggle("createRotation", isOpen)}
provider={(popUp.createRotation.data as TSecretRotationProviderTemplate) || {}}
/>
<DeleteActionModal
isOpen={popUp.deleteRotation.isOpen}
title="Are you sure want to delete this rotation?"
@@ -372,7 +413,7 @@ const Page = () => {
>
Secret Manager Dashboard
</Link>{" "}
to create a Secret Rotation.
to create a {popUp.secretRotationV2.data} Rotation.
</p>
<div className="overflow-clip rounded border border-mineshaft-600">
<img
@@ -19,6 +19,7 @@ import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinat
import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection";
import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection";
import { TerraformCloudSyncDestinationSection } from "./TerraformCloudSyncDestinationSection";
import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection";
type Props = {
@@ -59,6 +60,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.Humanitec:
DestinationComponents = <HumanitecSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.TerraformCloud:
DestinationComponents = <TerraformCloudSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.Camunda:
DestinationComponents = <CamundaSyncDestinationSection secretSync={secretSync} />;
break;
@@ -0,0 +1,65 @@
import { ReactNode } from "react";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TerraformCloudSyncCategory } from "@app/hooks/api/appConnections/terraform-cloud";
import {
TerraformCloudSyncScope,
TTerraformCloudSync
} from "@app/hooks/api/secretSyncs/types/terraform-cloud-sync";
type Props = {
secretSync: TTerraformCloudSync;
};
export const TerraformCloudSyncDestinationSection = ({ secretSync }: Props) => {
const { destinationConfig } = secretSync;
let Components: ReactNode;
switch (destinationConfig.scope) {
case TerraformCloudSyncScope.VariableSet:
Components = (
<>
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
<GenericFieldLabel label="Variable Set">
{destinationConfig.variableSetName}
</GenericFieldLabel>
<GenericFieldLabel label="Category">
{Object.keys(TerraformCloudSyncCategory).find(
(key) =>
TerraformCloudSyncCategory[key as keyof typeof TerraformCloudSyncCategory] ===
destinationConfig.category
)}
</GenericFieldLabel>
</>
);
break;
case TerraformCloudSyncScope.Workspace:
Components = (
<>
<GenericFieldLabel label="Organization">{destinationConfig.org}</GenericFieldLabel>
<GenericFieldLabel label="Workspace">{destinationConfig.workspaceName}</GenericFieldLabel>
<GenericFieldLabel label="Category">
{Object.keys(TerraformCloudSyncCategory).find(
(key) =>
TerraformCloudSyncCategory[key as keyof typeof TerraformCloudSyncCategory] ===
destinationConfig.category
)}
</GenericFieldLabel>
</>
);
break;
default:
throw new Error(
`Unhandled Terraform Cloud Sync Destination Section Scope ${secretSync.destinationConfig.scope}`
);
}
return (
<>
<GenericFieldLabel className="capitalize" label="Scope">
{destinationConfig.scope.replace("-", " ")}
</GenericFieldLabel>
{Components}
</>
);
};
@@ -48,6 +48,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.AzureAppConfiguration:
case SecretSync.Databricks:
case SecretSync.Humanitec:
case SecretSync.TerraformCloud:
case SecretSync.Camunda:
case SecretSync.Vercel:
AdditionalSyncOptionsComponent = null;