Compare commits

...

31 Commits

Author SHA1 Message Date
fb030401ab doc: add ab-initio docs 2025-02-26 13:37:19 +09:00
f4bd48fd1d Merge pull request #3142 from Infisical/sidebar-update
improve sidebars
2025-02-25 13:20:53 +04:00
177ccf6c9e Update SecretDetailSidebar.tsx 2025-02-25 18:15:27 +09:00
9200137d6c Merge pull request #3144 from Infisical/revert-3130-snyk-fix-9bc3e8652a6384afdd415f17c0d6ac68
Revert "[Snyk] Fix for 4 vulnerabilities"
2025-02-25 18:12:32 +09:00
a196028064 Revert "[Snyk] Fix for 4 vulnerabilities" 2025-02-25 18:12:12 +09:00
0c0e20f00e Merge pull request #3143 from Infisical/revert-3129-snyk-fix-e021ef688dc4b4af03b9ad04389eee3f
Revert "[Snyk] Security upgrade @octokit/rest from 21.0.2 to 21.1.1"
2025-02-25 18:11:56 +09:00
710429c805 Revert "[Snyk] Security upgrade @octokit/rest from 21.0.2 to 21.1.1" 2025-02-25 18:10:30 +09:00
c121bd930b fix nav 2025-02-25 18:03:13 +09:00
87d383a9c4 Update SecretDetailSidebar.tsx 2025-02-25 17:44:55 +09:00
6e590a78a0 fix lint issues 2025-02-25 17:30:15 +09:00
ab4b6c17b3 fix lint issues 2025-02-25 17:23:05 +09:00
27cd40c8ce fix lint issues 2025-02-25 17:20:52 +09:00
5f089e0b9d improve sidebars 2025-02-25 17:07:53 +09:00
19940522aa Merge pull request #3138 from Infisical/daniel/go-sdk-batch-create-docs
docs: go sdk bulk create secrets
2025-02-23 15:14:36 +09:00
28b18c1cb1 Merge pull request #3129 from Infisical/snyk-fix-e021ef688dc4b4af03b9ad04389eee3f
[Snyk] Security upgrade @octokit/rest from 21.0.2 to 21.1.1
2025-02-23 15:13:25 +09:00
7ae2cc2db8 Merge pull request #3130 from Infisical/snyk-fix-9bc3e8652a6384afdd415f17c0d6ac68
[Snyk] Fix for 4 vulnerabilities
2025-02-23 15:12:59 +09:00
4a51b4d619 Merge pull request #3139 from akhilmhdh/fix/shared-link-min-check
feat: added min check for secret sharing
2025-02-23 15:07:40 +09:00
478e0c5ff5 Merge pull request #3134 from Infisical/aws-secrets-manager-additional-features
Feature: AWS Syncs - Additional Features
2025-02-21 08:23:06 -08:00
5c08136fca improvement: address feedback 2025-02-21 07:51:15 -08:00
cb8528adc4 merge main 2025-02-21 07:39:24 -08:00
=
d7935d30ce feat: made the function shared one 2025-02-21 14:47:04 +05:30
=
ac3bab3074 feat: added min check for secret sharing 2025-02-21 14:38:34 +05:30
63b8301065 Merge pull request #3137 from Infisical/flyio-integration-propagate-errors
Fix: Propagate Set Secrets Errors for Flyio Integration
2025-02-20 16:26:35 -08:00
babe70e00f fix: propagate set secrets error for flyio integration 2025-02-20 16:06:58 -08:00
f23ea0991c improvement: address feedback 2025-02-20 11:48:47 -08:00
f8ab2bcdfd feature: kms key, tags, and sync secret metadata support for aws secrets manager 2025-02-19 20:38:18 -08:00
9cdb4dcde9 improvement: address feedback 2025-02-19 16:52:48 -08:00
69fb87bbfc reduce max height for resource tags 2025-02-18 20:32:57 -08:00
b0cd5bd10d feature: add support for kms key, tags, and syncing secret metadata to aws parameter store sync 2025-02-18 20:28:27 -08:00
15119ffda9 fix: backend/package.json & backend/package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-OCTOKITENDPOINT-8730856
- https://snyk.io/vuln/SNYK-JS-OCTOKITPLUGINPAGINATEREST-8730855
- https://snyk.io/vuln/SNYK-JS-OCTOKITREQUEST-8730853
- https://snyk.io/vuln/SNYK-JS-OCTOKITREQUESTERROR-8730854
2025-02-19 04:10:31 +00:00
4df409e627 fix: frontend/package.json & frontend/package-lock.json to reduce vulnerabilities
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-JS-OCTOKITENDPOINT-8730856
- https://snyk.io/vuln/SNYK-JS-OCTOKITPLUGINPAGINATEREST-8730855
- https://snyk.io/vuln/SNYK-JS-OCTOKITREQUEST-8730853
- https://snyk.io/vuln/SNYK-JS-OCTOKITREQUESTERROR-8730854
2025-02-19 03:23:48 +00:00
64 changed files with 2675 additions and 770 deletions

View File

@ -1722,6 +1722,18 @@ export const SecretSyncs = {
initialSyncBehavior: `Specify how Infisical should resolve the initial sync to the ${destinationName} destination.`
};
},
ADDITIONAL_SYNC_OPTIONS: {
AWS_PARAMETER_STORE: {
keyId: "The AWS KMS key ID or alias to use when encrypting parameters synced by Infisical.",
tags: "Optional resource tags to add to parameters synced by Infisical.",
syncSecretMetadataAsTags: `Whether Infisical secret metadata should be added as resource tags to parameters synced by Infisical.`
},
AWS_SECRETS_MANAGER: {
keyId: "The AWS KMS key ID or alias to use when encrypting parameters synced by Infisical.",
tags: "Optional tags to add to secrets synced by Infisical.",
syncSecretMetadataAsTags: `Whether Infisical secret metadata should be added as tags to secrets synced by Infisical.`
}
},
DESTINATION_CONFIG: {
AWS_PARAMETER_STORE: {
region: "The AWS region to sync secrets to.",

View File

@ -1,13 +1,19 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { z } from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
import {
CreateAwsConnectionSchema,
SanitizedAwsConnectionSchema,
UpdateAwsConnectionSchema
} from "@app/services/app-connection/aws";
import { AuthMode } from "@app/services/auth/auth-type";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerAwsConnectionRouter = async (server: FastifyZodProvider) =>
export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.AWS,
server,
@ -15,3 +21,42 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) =>
createSchema: CreateAwsConnectionSchema,
updateSchema: UpdateAwsConnectionSchema
});
// The below endpoints are not exposed and for Infisical App use
server.route({
method: "GET",
url: `/:connectionId/kms-keys`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
querystring: z.object({
region: z.nativeEnum(AWSRegion),
destination: z.enum([SecretSync.AWSParameterStore, SecretSync.AWSSecretsManager])
}),
response: {
200: z.object({
kmsKeys: z.object({ alias: z.string(), id: z.string() }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const kmsKeys = await server.services.appConnection.aws.listKmsKeys(
{
connectionId,
...req.query
},
req.permission
);
return { kmsKeys };
}
});
};

View File

@ -22,18 +22,19 @@ import {
TUpdateAppConnectionDTO,
TValidateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-types";
import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws";
import { ValidateDatabricksConnectionCredentialsSchema } from "@app/services/app-connection/databricks";
import { databricksConnectionService } from "@app/services/app-connection/databricks/databricks-connection-service";
import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github";
import { githubConnectionService } from "@app/services/app-connection/github/github-connection-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "./app-connection-dal";
import { ValidateAwsConnectionCredentialsSchema } from "./aws";
import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault";
import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks";
import { databricksConnectionService } from "./databricks/databricks-connection-service";
import { ValidateGcpConnectionCredentialsSchema } from "./gcp";
import { gcpConnectionService } from "./gcp/gcp-connection-service";
import { ValidateGitHubConnectionCredentialsSchema } from "./github";
import { githubConnectionService } from "./github/github-connection-service";
export type TAppConnectionServiceFactoryDep = {
appConnectionDAL: TAppConnectionDALFactory;
@ -369,6 +370,7 @@ export const appConnectionServiceFactory = ({
listAvailableAppConnectionsForUser,
github: githubConnectionService(connectAppConnectionById),
gcp: gcpConnectionService(connectAppConnectionById),
databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService)
databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
aws: awsConnectionService(connectAppConnectionById)
};
};

View File

@ -1,3 +1,4 @@
import { AWSRegion } from "@app/services/app-connection/app-connection-enums";
import {
TAwsConnection,
TAwsConnectionConfig,
@ -16,6 +17,7 @@ import {
TGitHubConnectionInput,
TValidateGitHubConnectionCredentials
} from "@app/services/app-connection/github";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
TAzureAppConfigurationConnection,
@ -73,3 +75,9 @@ export type TValidateAppConnectionCredentials =
| TValidateAzureKeyVaultConnectionCredentials
| TValidateAzureAppConfigurationConnectionCredentials
| TValidateDatabricksConnectionCredentials;
export type TListAwsConnectionKmsKeys = {
connectionId: string;
region: AWSRegion;
destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager;
};

View File

@ -0,0 +1,88 @@
import AWS from "aws-sdk";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { TListAwsConnectionKmsKeys } from "@app/services/app-connection/app-connection-types";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TAwsConnection>;
const listAwsKmsKeys = async (
appConnection: TAwsConnection,
{ region, destination }: Pick<TListAwsConnectionKmsKeys, "region" | "destination">
) => {
const { credentials } = await getAwsConnectionConfig(appConnection, region);
const awsKms = new AWS.KMS({
credentials,
region
});
const aliasEntries: AWS.KMS.AliasList = [];
let aliasMarker: string | undefined;
do {
// eslint-disable-next-line no-await-in-loop
const response = await awsKms.listAliases({ Limit: 100, Marker: aliasMarker }).promise();
aliasEntries.push(...(response.Aliases || []));
aliasMarker = response.NextMarker;
} while (aliasMarker);
const keyMetadataRecord: Record<string, AWS.KMS.KeyMetadata | undefined> = {};
for await (const aliasEntry of aliasEntries) {
if (aliasEntry.TargetKeyId) {
const keyDescription = await awsKms.describeKey({ KeyId: aliasEntry.TargetKeyId }).promise();
keyMetadataRecord[aliasEntry.TargetKeyId] = keyDescription.KeyMetadata;
}
}
const validAliasEntries = aliasEntries.filter((aliasEntry) => {
if (!aliasEntry.TargetKeyId) return false;
if (destination === SecretSync.AWSParameterStore && aliasEntry.AliasName === "alias/aws/ssm") return true;
if (destination === SecretSync.AWSSecretsManager && aliasEntry.AliasName === "alias/aws/secretsmanager")
return true;
if (aliasEntry.AliasName?.includes("alias/aws/")) return false;
const keyMetadata = keyMetadataRecord[aliasEntry.TargetKeyId];
if (!keyMetadata || keyMetadata.KeyUsage !== "ENCRYPT_DECRYPT" || keyMetadata.KeySpec !== "SYMMETRIC_DEFAULT")
return false;
return true;
});
const kmsKeys = validAliasEntries.map((aliasEntry) => {
return {
id: aliasEntry.TargetKeyId!,
alias: aliasEntry.AliasName!
};
});
return kmsKeys;
};
export const awsConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listKmsKeys = async (
{ connectionId, region, destination }: TListAwsConnectionKmsKeys,
actor: OrgServiceActor
) => {
const appConnection = await getAppConnection(AppConnection.AWS, connectionId, actor);
const kmsKeys = await listAwsKmsKeys(appConnection, { region, destination });
return kmsKeys;
};
return {
listKmsKeys
};
};

View File

@ -2257,7 +2257,9 @@ const syncSecretsFlyio = async ({
}
`;
await request.post(
type TFlyioErrors = { message: string }[];
const setSecretsResp = await request.post<{ errors?: TFlyioErrors }>(
IntegrationUrls.FLYIO_API_URL,
{
query: SetSecrets,
@ -2279,6 +2281,10 @@ const syncSecretsFlyio = async ({
}
);
if (setSecretsResp.data.errors?.length) {
throw new Error(JSON.stringify(setSecretsResp.data.errors));
}
// get secrets
interface FlyioSecret {
name: string;

View File

@ -34,6 +34,25 @@ export const secretSharingServiceFactory = ({
orgDAL,
kmsService
}: TSecretSharingServiceFactoryDep) => {
const $validateSharedSecretExpiry = (expiresAt: string) => {
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" });
}
const fiveMins = 5 * 60 * 1000;
if (expiryTime - currentTime < fiveMins) {
throw new BadRequestError({ message: "Expiration time cannot be less than 5 mins" });
}
};
const createSharedSecret = async ({
actor,
actorId,
@ -49,18 +68,7 @@ export const secretSharingServiceFactory = ({
}: TCreateSharedSecretDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
if (!permission) throw new ForbiddenRequestError({ name: "User is not a part of the specified organization" });
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" });
}
$validateSharedSecretExpiry(expiresAt);
if (secretValue.length > 10_000) {
throw new BadRequestError({ message: "Shared secret value too long" });
@ -100,17 +108,7 @@ export const secretSharingServiceFactory = ({
expiresAfterViews,
accessType
}: TCreatePublicSharedSecretDTO) => {
if (new Date(expiresAt) < new Date()) {
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
}
// Limit Expiry Time to 1 month
const expiryTime = new Date(expiresAt).getTime();
const currentTime = new Date().getTime();
const thirtyDays = 30 * 24 * 60 * 60 * 1000;
if (expiryTime - currentTime > thirtyDays) {
throw new BadRequestError({ message: "Expiration date cannot exceed more than 30 days" });
}
$validateSharedSecretExpiry(expiresAt);
const encryptWithRoot = kmsService.encryptWithRootKey();
const encryptedSecret = encryptWithRoot(Buffer.from(secretValue));

View File

@ -7,6 +7,8 @@ import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { TAwsParameterStoreSyncWithCredentials } from "./aws-parameter-store-sync-types";
type TAWSParameterStoreRecord = Record<string, AWS.SSM.Parameter>;
type TAWSParameterStoreMetadataRecord = Record<string, AWS.SSM.ParameterMetadata>;
type TAWSParameterStoreTagsRecord = Record<string, Record<string, string>>;
const MAX_RETRIES = 5;
const BATCH_SIZE = 10;
@ -80,6 +82,129 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
return awsParameterStoreSecretsRecord;
};
const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreMetadataRecord> => {
const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {};
let hasNext = true;
let nextToken: string | undefined;
let attempt = 0;
while (hasNext) {
try {
// eslint-disable-next-line no-await-in-loop
const parameters = await ssm
.describeParameters({
MaxResults: 10,
NextToken: nextToken,
ParameterFilters: [
{
Key: "Path",
Option: "OneLevel",
Values: [path]
}
]
})
.promise();
attempt = 0;
if (parameters.Parameters) {
parameters.Parameters.forEach((parameter) => {
if (parameter.Name) {
// no leading slash if path is '/'
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
awsParameterStoreMetadataRecord[secKey] = parameter;
}
});
}
hasNext = Boolean(parameters.NextToken);
nextToken = parameters.NextToken;
} catch (e) {
if ((e as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
attempt += 1;
// eslint-disable-next-line no-await-in-loop
await sleep();
// eslint-disable-next-line no-continue
continue;
}
throw e;
}
}
return awsParameterStoreMetadataRecord;
};
const getParameterStoreTagsRecord = async (
ssm: AWS.SSM,
awsParameterStoreSecretsRecord: TAWSParameterStoreRecord,
needsTagsPermissions: boolean
): Promise<{ shouldManageTags: boolean; awsParameterStoreTagsRecord: TAWSParameterStoreTagsRecord }> => {
const awsParameterStoreTagsRecord: TAWSParameterStoreTagsRecord = {};
for await (const entry of Object.entries(awsParameterStoreSecretsRecord)) {
const [key, parameter] = entry;
if (!parameter.Name) {
// eslint-disable-next-line no-continue
continue;
}
try {
const tags = await ssm
.listTagsForResource({
ResourceType: "Parameter",
ResourceId: parameter.Name
})
.promise();
awsParameterStoreTagsRecord[key] = Object.fromEntries(tags.TagList?.map((tag) => [tag.Key, tag.Value]) ?? []);
} catch (e) {
// users aren't required to provide tag permissions to use sync so we handle gracefully if unauthorized
// and they aren't trying to configure tags
if ((e as AWSError).code === "AccessDeniedException") {
if (!needsTagsPermissions) {
return { shouldManageTags: false, awsParameterStoreTagsRecord: {} };
}
throw new SecretSyncError({
message:
"IAM role has inadequate permissions to manage resource tags. Ensure the following polices are present: ssm:ListTagsForResource, ssm:AddTagsToResource, and ssm:RemoveTagsFromResource",
shouldRetry: false
});
}
throw e;
}
}
return { shouldManageTags: true, awsParameterStoreTagsRecord };
};
const processParameterTags = ({
syncTagsRecord,
awsTagsRecord
}: {
syncTagsRecord: Record<string, string>;
awsTagsRecord: Record<string, string>;
}) => {
const tagsToAdd: AWS.SSM.TagList = [];
const tagKeysToRemove: string[] = [];
for (const syncEntry of Object.entries(syncTagsRecord)) {
const [syncKey, syncValue] = syncEntry;
if (!(syncKey in awsTagsRecord) || syncValue !== awsTagsRecord[syncKey])
tagsToAdd.push({ Key: syncKey, Value: syncValue });
}
for (const awsKey of Object.keys(awsTagsRecord)) {
if (!(awsKey in syncTagsRecord)) tagKeysToRemove.push(awsKey);
}
return { tagsToAdd, tagKeysToRemove };
};
const putParameter = async (
ssm: AWS.SSM,
params: AWS.SSM.PutParameterRequest,
@ -98,6 +223,42 @@ const putParameter = async (
}
};
const addTagsToParameter = async (
ssm: AWS.SSM,
params: Omit<AWS.SSM.AddTagsToResourceRequest, "ResourceType">,
attempt = 0
): Promise<AWS.SSM.AddTagsToResourceResult> => {
try {
return await ssm.addTagsToResource({ ...params, ResourceType: "Parameter" }).promise();
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return addTagsToParameter(ssm, params, attempt + 1);
}
throw error;
}
};
const removeTagsFromParameter = async (
ssm: AWS.SSM,
params: Omit<AWS.SSM.RemoveTagsFromResourceRequest, "ResourceType">,
attempt = 0
): Promise<AWS.SSM.RemoveTagsFromResourceResult> => {
try {
return await ssm.removeTagsFromResource({ ...params, ResourceType: "Parameter" }).promise();
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return removeTagsFromParameter(ssm, params, attempt + 1);
}
throw error;
}
};
const deleteParametersBatch = async (
ssm: AWS.SSM,
parameters: AWS.SSM.Parameter[],
@ -132,35 +293,92 @@ const deleteParametersBatch = async (
export const AwsParameterStoreSyncFns = {
syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig } = secretSync;
const { destinationConfig, syncOptions } = secretSync;
const ssm = await getSSM(secretSync);
// TODO(scott): KMS Key ID, Tags
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
for await (const entry of Object.entries(secretMap)) {
const [key, { value }] = entry;
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path);
// skip empty values (not allowed by AWS) or secrets that haven't changed
if (!value || (key in awsParameterStoreSecretsRecord && awsParameterStoreSecretsRecord[key].Value === value)) {
const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord(
ssm,
awsParameterStoreSecretsRecord,
Boolean(syncOptions.tags?.length || syncOptions.syncSecretMetadataAsTags)
);
const syncTagsRecord = Object.fromEntries(syncOptions.tags?.map((tag) => [tag.key, tag.value]) ?? []);
for await (const entry of Object.entries(secretMap)) {
const [key, { value, secretMetadata }] = entry;
// skip empty values (not allowed by AWS)
if (!value) {
// eslint-disable-next-line no-continue
continue;
}
try {
await putParameter(ssm, {
Name: `${destinationConfig.path}${key}`,
Type: "SecureString",
Value: value,
Overwrite: true
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
const keyId = syncOptions.keyId ?? "alias/aws/ssm";
// create parameter or update if changed
if (
!(key in awsParameterStoreSecretsRecord) ||
value !== awsParameterStoreSecretsRecord[key].Value ||
keyId !== awsParameterStoreMetadataRecord[key]?.KeyId
) {
try {
await putParameter(ssm, {
Name: `${destinationConfig.path}${key}`,
Type: "SecureString",
Value: value,
Overwrite: true,
KeyId: keyId
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (shouldManageTags) {
const { tagsToAdd, tagKeysToRemove } = processParameterTags({
syncTagsRecord: {
// configured sync tags take preference over secret metadata
...(syncOptions.syncSecretMetadataAsTags &&
Object.fromEntries(secretMetadata?.map((tag) => [tag.key, tag.value]) ?? [])),
...syncTagsRecord
},
awsTagsRecord: awsParameterStoreTagsRecord[key] ?? {}
});
if (tagsToAdd.length) {
try {
await addTagsToParameter(ssm, {
ResourceId: `${destinationConfig.path}${key}`,
Tags: tagsToAdd
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (tagKeysToRemove.length) {
try {
await removeTagsFromParameter(ssm, {
ResourceId: `${destinationConfig.path}${key}`,
TagKeys: tagKeysToRemove
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
}
}

View File

@ -8,6 +8,7 @@ import {
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const AwsParameterStoreSyncDestinationConfigSchema = z.object({
region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.region),
@ -20,19 +21,68 @@ const AwsParameterStoreSyncDestinationConfigSchema = z.object({
.describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.path)
});
export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(SecretSync.AWSParameterStore).extend({
const AwsParameterStoreSyncOptionsSchema = z.object({
keyId: z
.string()
.regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID")
.min(1, "Invalid KMS Key ID")
.max(256, "Invalid KMS Key ID")
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.keyId),
tags: z
.object({
key: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.min(1, "Resource tag key required")
.max(128, "Resource tag key cannot exceed 128 characters"),
value: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Invalid resource tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.max(256, "Resource tag value cannot exceed 256 characters")
})
.array()
.max(50)
.refine((items) => new Set(items.map((item) => item.key)).size === items.length, {
message: "Resource tag keys must be unique"
})
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.tags),
syncSecretMetadataAsTags: z
.boolean()
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.syncSecretMetadataAsTags)
});
const AwsParameterStoreSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(
SecretSync.AWSParameterStore,
AwsParameterStoreSyncOptionsConfig,
AwsParameterStoreSyncOptionsSchema
).extend({
destination: z.literal(SecretSync.AWSParameterStore),
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema
});
export const CreateAwsParameterStoreSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.AWSParameterStore
SecretSync.AWSParameterStore,
AwsParameterStoreSyncOptionsConfig,
AwsParameterStoreSyncOptionsSchema
).extend({
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema
});
export const UpdateAwsParameterStoreSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.AWSParameterStore
SecretSync.AWSParameterStore,
AwsParameterStoreSyncOptionsConfig,
AwsParameterStoreSyncOptionsSchema
).extend({
destinationConfig: AwsParameterStoreSyncDestinationConfigSchema.optional()
});

View File

@ -1,16 +1,28 @@
import { UntagResourceCommandOutput } from "@aws-sdk/client-kms";
import {
BatchGetSecretValueCommand,
CreateSecretCommand,
CreateSecretCommandInput,
DeleteSecretCommand,
DeleteSecretResponse,
DescribeSecretCommand,
DescribeSecretCommandInput,
ListSecretsCommand,
SecretsManagerClient,
TagResourceCommand,
TagResourceCommandOutput,
UntagResourceCommand,
UpdateSecretCommand,
UpdateSecretCommandInput
} from "@aws-sdk/client-secrets-manager";
import { AWSError } from "aws-sdk";
import { CreateSecretResponse, SecretListEntry, SecretValueEntry } from "aws-sdk/clients/secretsmanager";
import {
CreateSecretResponse,
DescribeSecretResponse,
SecretListEntry,
SecretValueEntry,
Tag
} from "aws-sdk/clients/secretsmanager";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { AwsSecretsManagerSyncMappingBehavior } from "@app/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-enums";
@ -21,6 +33,7 @@ import { TAwsSecretsManagerSyncWithCredentials } from "./aws-secrets-manager-syn
type TAwsSecretsRecord = Record<string, SecretListEntry>;
type TAwsSecretValuesRecord = Record<string, SecretValueEntry>;
type TAwsSecretDescriptionsRecord = Record<string, DescribeSecretResponse>;
const MAX_RETRIES = 5;
const BATCH_SIZE = 20;
@ -135,6 +148,46 @@ const getSecretValuesRecord = async (
return awsSecretValuesRecord;
};
const describeSecret = async (
client: SecretsManagerClient,
input: DescribeSecretCommandInput,
attempt = 0
): Promise<DescribeSecretResponse> => {
try {
return await client.send(new DescribeSecretCommand(input));
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return describeSecret(client, input, attempt + 1);
}
throw error;
}
};
const getSecretDescriptionsRecord = async (
client: SecretsManagerClient,
awsSecretsRecord: TAwsSecretsRecord
): Promise<TAwsSecretDescriptionsRecord> => {
const awsSecretDescriptionsRecord: TAwsSecretValuesRecord = {};
for await (const secretKey of Object.keys(awsSecretsRecord)) {
try {
awsSecretDescriptionsRecord[secretKey] = await describeSecret(client, {
SecretId: secretKey
});
} catch (error) {
throw new SecretSyncError({
secretKey,
error
});
}
}
return awsSecretDescriptionsRecord;
};
const createSecret = async (
client: SecretsManagerClient,
input: CreateSecretCommandInput,
@ -189,9 +242,71 @@ const deleteSecret = async (
}
};
const addTags = async (
client: SecretsManagerClient,
secretKey: string,
tags: Tag[],
attempt = 0
): Promise<TagResourceCommandOutput> => {
try {
return await client.send(new TagResourceCommand({ SecretId: secretKey, Tags: tags }));
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return addTags(client, secretKey, tags, attempt + 1);
}
throw error;
}
};
const removeTags = async (
client: SecretsManagerClient,
secretKey: string,
tagKeys: string[],
attempt = 0
): Promise<UntagResourceCommandOutput> => {
try {
return await client.send(new UntagResourceCommand({ SecretId: secretKey, TagKeys: tagKeys }));
} catch (error) {
if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) {
await sleep();
// retry
return removeTags(client, secretKey, tagKeys, attempt + 1);
}
throw error;
}
};
const processTags = ({
syncTagsRecord,
awsTagsRecord
}: {
syncTagsRecord: Record<string, string>;
awsTagsRecord: Record<string, string>;
}) => {
const tagsToAdd: Tag[] = [];
const tagKeysToRemove: string[] = [];
for (const syncEntry of Object.entries(syncTagsRecord)) {
const [syncKey, syncValue] = syncEntry;
if (!(syncKey in awsTagsRecord) || syncValue !== awsTagsRecord[syncKey])
tagsToAdd.push({ Key: syncKey, Value: syncValue });
}
for (const awsKey of Object.keys(awsTagsRecord)) {
if (!(awsKey in syncTagsRecord)) tagKeysToRemove.push(awsKey);
}
return { tagsToAdd, tagKeysToRemove };
};
export const AwsSecretsManagerSyncFns = {
syncSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => {
const { destinationConfig } = secretSync;
const { destinationConfig, syncOptions } = secretSync;
const client = await getSecretsManagerClient(secretSync);
@ -199,9 +314,15 @@ export const AwsSecretsManagerSyncFns = {
const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord);
const awsDescriptionsRecord = await getSecretDescriptionsRecord(client, awsSecretsRecord);
const syncTagsRecord = Object.fromEntries(syncOptions.tags?.map((tag) => [tag.key, tag.value]) ?? []);
const keyId = syncOptions.keyId ?? "alias/aws/secretsmanager";
if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) {
for await (const entry of Object.entries(secretMap)) {
const [key, { value }] = entry;
const [key, { value, secretMetadata }] = entry;
// skip secrets that don't have a value set
if (!value) {
@ -211,15 +332,26 @@ export const AwsSecretsManagerSyncFns = {
if (awsSecretsRecord[key]) {
// skip secrets that haven't changed
if (awsValuesRecord[key]?.SecretString === value) {
// eslint-disable-next-line no-continue
continue;
if (awsValuesRecord[key]?.SecretString !== value || keyId !== awsDescriptionsRecord[key]?.KmsKeyId) {
try {
await updateSecret(client, {
SecretId: key,
SecretString: value,
KmsKeyId: keyId
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
} else {
try {
await updateSecret(client, {
SecretId: key,
SecretString: value
await createSecret(client, {
Name: key,
SecretString: value,
KmsKeyId: keyId
});
} catch (error) {
throw new SecretSyncError({
@ -227,12 +359,34 @@ export const AwsSecretsManagerSyncFns = {
secretKey: key
});
}
} else {
}
const { tagsToAdd, tagKeysToRemove } = processTags({
syncTagsRecord: {
// configured sync tags take preference over secret metadata
...(syncOptions.syncSecretMetadataAsTags &&
Object.fromEntries(secretMetadata?.map((tag) => [tag.key, tag.value]) ?? [])),
...syncTagsRecord
},
awsTagsRecord: Object.fromEntries(
awsDescriptionsRecord[key]?.Tags?.map((tag) => [tag.Key!, tag.Value!]) ?? []
)
});
if (tagsToAdd.length) {
try {
await createSecret(client, {
Name: key,
SecretString: value
await addTags(client, key, tagsToAdd);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (tagKeysToRemove.length) {
try {
await removeTags(client, key, tagKeysToRemove);
} catch (error) {
throw new SecretSyncError({
error,
@ -261,17 +415,48 @@ export const AwsSecretsManagerSyncFns = {
Object.fromEntries(Object.entries(secretMap).map(([key, secretData]) => [key, secretData.value]))
);
if (awsValuesRecord[destinationConfig.secretName]) {
if (awsSecretsRecord[destinationConfig.secretName]) {
await updateSecret(client, {
SecretId: destinationConfig.secretName,
SecretString: secretValue
SecretString: secretValue,
KmsKeyId: keyId
});
} else {
await createSecret(client, {
Name: destinationConfig.secretName,
SecretString: secretValue
SecretString: secretValue,
KmsKeyId: keyId
});
}
const { tagsToAdd, tagKeysToRemove } = processTags({
syncTagsRecord,
awsTagsRecord: Object.fromEntries(
awsDescriptionsRecord[destinationConfig.secretName]?.Tags?.map((tag) => [tag.Key!, tag.Value!]) ?? []
)
});
if (tagsToAdd.length) {
try {
await addTags(client, destinationConfig.secretName, tagsToAdd);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: destinationConfig.secretName
});
}
}
if (tagKeysToRemove.length) {
try {
await removeTags(client, destinationConfig.secretName, tagKeysToRemove);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: destinationConfig.secretName
});
}
}
}
},
getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise<TSecretMap> => {

View File

@ -9,6 +9,7 @@ import {
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const AwsSecretsManagerSyncDestinationConfigSchema = z
.discriminatedUnion("mappingBehavior", [
@ -38,22 +39,95 @@ const AwsSecretsManagerSyncDestinationConfigSchema = z
})
);
export const AwsSecretsManagerSyncSchema = BaseSecretSyncSchema(SecretSync.AWSSecretsManager).extend({
const AwsSecretsManagerSyncOptionsSchema = z.object({
keyId: z
.string()
.regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID")
.min(1, "Invalid KMS Key ID")
.max(256, "Invalid KMS Key ID")
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.keyId),
tags: z
.object({
key: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Invalid tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.min(1, "Tag key required")
.max(128, "Tag key cannot exceed 128 characters"),
value: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Invalid tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.max(256, "Tag value cannot exceed 256 characters")
})
.array()
.max(50)
.refine((items) => new Set(items.map((item) => item.key)).size === items.length, {
message: "Tag keys must be unique"
})
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.tags),
syncSecretMetadataAsTags: z
.boolean()
.optional()
.describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.syncSecretMetadataAsTags)
});
const AwsSecretsManagerSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const AwsSecretsManagerSyncSchema = BaseSecretSyncSchema(
SecretSync.AWSSecretsManager,
AwsSecretsManagerSyncOptionsConfig,
AwsSecretsManagerSyncOptionsSchema
).extend({
destination: z.literal(SecretSync.AWSSecretsManager),
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema
});
export const CreateAwsSecretsManagerSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.AWSSecretsManager
).extend({
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema
});
SecretSync.AWSSecretsManager,
AwsSecretsManagerSyncOptionsConfig,
AwsSecretsManagerSyncOptionsSchema
)
.extend({
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema
})
.superRefine((sync, ctx) => {
if (
sync.destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne &&
sync.syncOptions.syncSecretMetadataAsTags
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Syncing secret metadata is not supported with "Many-to-One" mapping behavior.'
});
}
});
export const UpdateAwsSecretsManagerSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.AWSSecretsManager
).extend({
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema.optional()
});
SecretSync.AWSSecretsManager,
AwsSecretsManagerSyncOptionsConfig,
AwsSecretsManagerSyncOptionsSchema
)
.extend({
destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema.optional()
})
.superRefine((sync, ctx) => {
if (
sync.destinationConfig?.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne &&
sync.syncOptions.syncSecretMetadataAsTags
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Syncing secret metadata is not supported with "Many-to-One" mapping behavior.'
});
}
});
export const AwsSecretsManagerSyncListItemSchema = z.object({
name: z.literal("AWS Secrets Manager"),

View File

@ -233,6 +233,7 @@ export const secretSyncQueueFactory = ({
}
secretMap[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding);
secretMap[secretKey].secretMetadata = secret.secretMetadata;
})
);
@ -258,7 +259,8 @@ export const secretSyncQueueFactory = ({
secretMap[importedSecret.key] = {
skipMultilineEncoding: importedSecret.skipMultilineEncoding,
comment: importedSecret.secretComment,
value: importedSecret.secretValue || ""
value: importedSecret.secretValue || "",
secretMetadata: importedSecret.secretMetadata
};
}
}

View File

@ -1,4 +1,4 @@
import { z } from "zod";
import { AnyZodObject, z } from "zod";
import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs";
import { SecretSyncs } from "@app/lib/api-docs";
@ -8,34 +8,45 @@ import { SecretSync, SecretSyncInitialSyncBehavior } from "@app/services/secret-
import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const SyncOptionsSchema = (secretSync: SecretSync, options: TSyncOptionsConfig = { canImportSecrets: true }) =>
z.object({
initialSyncBehavior: (options.canImportSecrets
const BaseSyncOptionsSchema = <T extends AnyZodObject | undefined = undefined>({
destination,
syncOptionsConfig: { canImportSecrets },
merge,
isUpdateSchema
}: {
destination: SecretSync;
syncOptionsConfig: TSyncOptionsConfig;
merge?: T;
isUpdateSchema?: boolean;
}) => {
const baseSchema = z.object({
initialSyncBehavior: (canImportSecrets
? z.nativeEnum(SecretSyncInitialSyncBehavior)
: z.literal(SecretSyncInitialSyncBehavior.OverwriteDestination)
).describe(SecretSyncs.SYNC_OPTIONS(secretSync).initialSyncBehavior)
// prependPrefix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional()
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).PREPEND_PREFIX),
// appendSuffix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional()
// .describe(SecretSyncs.SYNC_OPTIONS(secretSync).APPEND_SUFFIX)
).describe(SecretSyncs.SYNC_OPTIONS(destination).initialSyncBehavior)
});
export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
const schema = merge ? baseSchema.merge(merge) : baseSchema;
return (
isUpdateSchema
? schema.describe(SecretSyncs.UPDATE(destination).syncOptions).optional()
: schema.describe(SecretSyncs.CREATE(destination).syncOptions)
) as T extends AnyZodObject ? z.ZodObject<z.objectUtil.MergeShapes<typeof schema.shape, T["shape"]>> : typeof schema;
};
export const BaseSecretSyncSchema = <T extends AnyZodObject | undefined = undefined>(
destination: SecretSync,
syncOptionsConfig: TSyncOptionsConfig,
merge?: T
) =>
SecretSyncsSchema.omit({
destination: true,
destinationConfig: true,
syncOptions: true
}).extend({
// destination needs to be on the extended object for type differentiation
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig),
syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge }),
// join properties
projectId: z.string(),
connection: z.object({
@ -47,7 +58,11 @@ export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?
folder: z.object({ id: z.string(), path: z.string() }).nullable()
});
export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
export const GenericCreateSecretSyncFieldsSchema = <T extends AnyZodObject | undefined = undefined>(
destination: SecretSync,
syncOptionsConfig: TSyncOptionsConfig,
merge?: T
) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretSyncs.CREATE(destination).name),
projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.CREATE(destination).projectId),
@ -66,10 +81,14 @@ export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syn
.transform(removeTrailingSlash)
.describe(SecretSyncs.CREATE(destination).secretPath),
isAutoSyncEnabled: z.boolean().default(true).describe(SecretSyncs.CREATE(destination).isAutoSyncEnabled),
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig).describe(SecretSyncs.CREATE(destination).syncOptions)
syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge })
});
export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) =>
export const GenericUpdateSecretSyncFieldsSchema = <T extends AnyZodObject | undefined = undefined>(
destination: SecretSync,
syncOptionsConfig: TSyncOptionsConfig,
merge?: T
) =>
z.object({
name: slugSchema({ field: "name" }).describe(SecretSyncs.UPDATE(destination).name).optional(),
connectionId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).connectionId).optional(),
@ -90,7 +109,5 @@ export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syn
.optional()
.describe(SecretSyncs.UPDATE(destination).secretPath),
isAutoSyncEnabled: z.boolean().optional().describe(SecretSyncs.UPDATE(destination).isAutoSyncEnabled),
syncOptions: SyncOptionsSchema(destination, syncOptionsConfig)
.optional()
.describe(SecretSyncs.UPDATE(destination).syncOptions)
syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge, isUpdateSchema: true })
});

View File

@ -2,6 +2,7 @@ import { Job } from "bullmq";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { QueueJobs } from "@app/queue";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import {
TAwsSecretsManagerSync,
TAwsSecretsManagerSyncInput,
@ -197,5 +198,10 @@ export type TSendSecretSyncFailedNotificationsJobDTO = Job<
export type TSecretMap = Record<
string,
{ value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined }
{
value: string;
comment?: string;
skipMultilineEncoding?: boolean | null | undefined;
secretMetadata?: ResourceMetadataDTO;
}
>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 KiB

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 KiB

After

Width:  |  Height:  |  Size: 878 KiB

View File

@ -82,22 +82,26 @@ Infisical supports two methods for connecting to AWS.
"Sid": "AllowSecretsManagerAccess",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:CreateSecret",
"secretsmanager:UpdateSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"kms:ListKeys",
"kms:ListAliases",
"kms:Encrypt",
"kms:Decrypt"
"secretsmanager:ListSecrets",
"secretsmanager:GetSecretValue",
"secretsmanager:BatchGetSecretValue",
"secretsmanager:CreateSecret",
"secretsmanager:UpdateSecret",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"kms:ListAliases", // if you need to specify the KMS key
"kms:Encrypt", // if you need to specify the KMS key
"kms:Decrypt", // if you need to specify the KMS key
"kms:DescribeKey" // if you need to specify the KMS key
],
"Resource": "*"
}
]
}
```
<Note>If using a custom KMS key, be sure to add the IAM user or role as a key user. ![KMS Key IAM Role User](/images/app-connections/aws/kms-key-user.png)</Note>
</Accordion>
<Accordion title="AWS Parameter Store">
Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store:
@ -112,23 +116,25 @@ Infisical supports two methods for connecting to AWS.
"Sid": "AllowSSMAccess",
"Effect": "Allow",
"Action": [
"ssm:PutParameter",
"ssm:DeleteParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath",
"ssm:DescribeParameters",
"ssm:DeleteParameters",
"ssm:AddTagsToResource", // if you need to add tags to secrets
"kms:ListKeys", // if you need to specify the KMS key
"kms:ListAliases", // if you need to specify the KMS key
"kms:Encrypt", // if you need to specify the KMS key
"kms:Decrypt" // if you need to specify the KMS key
"ssm:PutParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath",
"ssm:DescribeParameters",
"ssm:DeleteParameters",
"ssm:ListTagsForResource", // if you need to add tags to secrets
"ssm:AddTagsToResource", // if you need to add tags to secrets
"ssm:RemoveTagsFromResource", // if you need to add tags to secrets
"kms:ListAliases", // if you need to specify the KMS key
"kms:Encrypt", // if you need to specify the KMS key
"kms:Decrypt", // if you need to specify the KMS key
"kms:DescribeKey" // if you need to specify the KMS key
],
"Resource": "*"
}
]
}
```
<Note>If using a custom KMS key, be sure to add the IAM user or role as a key user. ![KMS Key IAM Role User](/images/app-connections/aws/kms-key-user.png)</Note>
</Accordion>
</AccordionGroup>
</Tab>
@ -223,22 +229,26 @@ Infisical supports two methods for connecting to AWS.
"Sid": "AllowSecretsManagerAccess",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:CreateSecret",
"secretsmanager:UpdateSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"kms:ListKeys",
"kms:ListAliases",
"kms:Encrypt",
"kms:Decrypt"
"secretsmanager:ListSecrets",
"secretsmanager:GetSecretValue",
"secretsmanager:BatchGetSecretValue",
"secretsmanager:CreateSecret",
"secretsmanager:UpdateSecret",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"kms:ListAliases", // if you need to specify the KMS key
"kms:Encrypt", // if you need to specify the KMS key
"kms:Decrypt", // if you need to specify the KMS key
"kms:DescribeKey" // if you need to specify the KMS key
],
"Resource": "*"
}
]
}
```
<Note>If using a custom KMS key, be sure to add the IAM user or role as a key user. ![KMS Key IAM Role User](/images/app-connections/aws/kms-key-user.png)</Note>
</Accordion>
<Accordion title="AWS Parameter Store">
Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store:
@ -253,23 +263,25 @@ Infisical supports two methods for connecting to AWS.
"Sid": "AllowSSMAccess",
"Effect": "Allow",
"Action": [
"ssm:PutParameter",
"ssm:DeleteParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath",
"ssm:DescribeParameters",
"ssm:DeleteParameters",
"ssm:AddTagsToResource", // if you need to add tags to secrets
"kms:ListKeys", // if you need to specify the KMS key
"kms:ListAliases", // if you need to specify the KMS key
"kms:Encrypt", // if you need to specify the KMS key
"kms:Decrypt" // if you need to specify the KMS key
"ssm:PutParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath",
"ssm:DescribeParameters",
"ssm:DeleteParameters",
"ssm:ListTagsForResource", // if you need to add tags to secrets
"ssm:AddTagsToResource", // if you need to add tags to secrets
"ssm:RemoveTagsFromResource", // if you need to add tags to secrets
"kms:ListAliases", // if you need to specify the KMS key
"kms:Encrypt", // if you need to specify the KMS key
"kms:Decrypt", // if you need to specify the KMS key
"kms:DescribeKey" // if you need to specify the KMS key
],
"Resource": "*"
}
]
}
```
<Note>If using a custom KMS key, be sure to add the IAM user or role as a key user. ![KMS Key IAM Role User](/images/app-connections/aws/kms-key-user.png)</Note>
</Accordion>
</AccordionGroup>
</Tab>

View File

@ -0,0 +1,32 @@
---
title: "AB Initio"
description: "How to use Infisical secrets in AB Initio."
---
## Prerequisites
- Set up and add envars to [Infisical](https://app.infisical.com).
- Install the [Infisical CLI](https://infisical.com/docs/cli/overview) to your server.
## Setup
<Steps>
<Step title="Authorize Infisical for AB Initio">
Create a [machine identity](https://infisical.com/docs/documentation/platform/identities/machine-identities#machine-identities) in Infisical and give it the appropriate read permissions for the desired project and secret paths.
</Step>
<Step title="Add Infisical CLI to your workflow">
Update your AB Initio workflows to use Infisical CLI to inject Infisical secrets as environment variables.
```bash
# Login using the machine identity. Modify this accordingly based on the authentication method used.
export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id=$INFISICAL_CLIENT_ID --client-secret=$INFISICAL_CLIENT_SECRET --silent --plain)
# Fetch secrets from Infisical
infisical export --projectId="<>" --env="prod" > infisical.env
# Inject secrets as environment variables
source infisical.env
```
</Step>
</Steps>

View File

@ -40,6 +40,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical."
- **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical.
- **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Parameter Store when keys conflict.
- **Import Secrets (Prioritize AWS Parameter Store)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Parameter Store over Infisical when keys conflict.
- **KMS Key**: The AWS KMS key ID or alias to encrypt parameters with.
- **Tags**: Optional resource tags to add to parameters synced by Infisical.
- **Sync Secret Metadata as Resource Tags**: If enabled, metadata attached to secrets will be added as resource tags to parameters synced by Infisical.
<Note>Manually configured tags from the **Tags** field will take precedence over secret metadata when tag keys conflict.</Note>
- **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only.
6. Configure the **Details** of your Parameter Store Sync, then click **Next**.

View File

@ -43,6 +43,9 @@ description: "Learn how to configure an AWS Secrets Manager Sync for Infisical."
- **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical.
- **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict.
- **Import Secrets (Prioritize AWS Secrets Manager)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict.
- **KMS Key**: The AWS KMS key ID or alias to encrypt secrets with.
- **Tags**: Optional tags to add to secrets synced by Infisical.
- **Sync Secret Metadata as Tags**: If enabled, metadata attached to secrets will be added as tags to secrets synced by Infisical.
- **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only.
6. Configure the **Details** of your Secrets Manager Sync, then click **Next**.

View File

@ -77,6 +77,13 @@ via the UI or API for the third-party service you intend to sync secrets to.
- <strong>Destination:</strong> The App Connection to utilize and the destination endpoint to deploy secrets to. These can vary between services.
- <strong>Options:</strong> Customize how secrets should be synced. Examples include adding a suffix or prefix to your secrets, or importing secrets from the destination on the initial sync.
<Note>
Secret Syncs are the source of truth for connected third-party services. Any secret,
including associated data, not present or imported in Infisical before syncing will be
overwritten, and changes made directly in the connected service outside of infisical may also
be overwritten by future syncs.
</Note>
<Info>
Some third-party services do not support importing secrets.
</Info>

View File

@ -515,7 +515,8 @@
"integrations/frameworks/laravel",
"integrations/frameworks/rails",
"integrations/frameworks/dotnet",
"integrations/platforms/pm2"
"integrations/platforms/pm2",
"integrations/frameworks/ab-initio"
]
}
]

View File

@ -1,11 +1,13 @@
import { useState } from "react";
import { Controller, FormProvider, useForm } from "react-hook-form";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Tab } from "@headlessui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, Checkbox, FormControl, Switch } from "@app/components/v2";
import { Button, FormControl, Switch } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import {
@ -16,10 +18,10 @@ import {
useSecretSyncOption
} from "@app/hooks/api/secretSyncs";
import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields/SecretSyncOptionsFields";
import { SecretSyncFormSchema, TSecretSyncForm } from "./schemas";
import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields";
import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields";
import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields";
import { SecretSyncReviewFields } from "./SecretSyncReviewFields";
import { SecretSyncSourceFields } from "./SecretSyncSourceFields";
@ -32,7 +34,7 @@ type Props = {
const FORM_TABS: { name: string; key: string; fields: (keyof TSecretSyncForm)[] }[] = [
{ name: "Source", key: "source", fields: ["secretPath", "environment"] },
{ name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] },
{ name: "Options", key: "options", fields: ["syncOptions"] },
{ name: "Sync Options", key: "options", fields: ["syncOptions"] },
{ name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Review", key: "review", fields: [] }
];
@ -42,8 +44,9 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop
const { currentWorkspace } = useWorkspace();
const { name: destinationName } = SECRET_SYNC_MAP[destination];
const [showConfirmation, setShowConfirmation] = useState(false);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const [confirmOverwrite, setConfirmOverwrite] = useState(false);
const { syncOption } = useSecretSyncOption(destination);
@ -77,6 +80,7 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop
onComplete(secretSync);
} catch (err: any) {
console.error(err);
setShowConfirmation(false);
createNotification({
title: `Failed to add ${destinationName} Sync`,
text: err.message,
@ -94,7 +98,7 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop
setSelectedTabIndex((prev) => prev - 1);
};
const { handleSubmit, trigger, watch, control } = formMethods;
const { handleSubmit, trigger, control } = formMethods;
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
@ -102,7 +106,7 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop
const handleNext = async () => {
if (isFinalStep) {
handleSubmit(onSubmit)();
setShowConfirmation(true);
return;
}
@ -123,7 +127,42 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop
return isEnabled;
};
const initialSyncBehavior = watch("syncOptions.initialSyncBehavior");
if (showConfirmation)
return (
<>
<div className="flex flex-col rounded-sm border border-l-[2px] border-mineshaft-600 border-l-primary bg-mineshaft-700/80 px-4 py-3">
<div className="mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="mr-1.5 text-primary" />
Secret Sync Behavior
</div>
<p className="mt-1 text-sm text-bunker-200">
Secret Syncs are the source of truth for connected third-party services. Any secret,
including associated data, not present or imported in Infisical before syncing will be
overwritten, and changes made directly in the connected service outside of infisical may
also be overwritten by future syncs.
</p>
</div>
<div className="mt-4 flex gap-4">
<Button
isDisabled={createSecretSync.isPending}
isLoading={createSecretSync.isPending}
onClick={handleSubmit(onSubmit)}
colorSchema="secondary"
>
I Understand
</Button>
<Button
isDisabled={createSecretSync.isPending}
variant="plain"
onClick={() => setShowConfirmation(false)}
colorSchema="secondary"
>
Cancel
</Button>
</div>
</>
);
return (
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}>
@ -174,7 +213,7 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/50"
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="auto-sync-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
@ -196,32 +235,9 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop
</Tab.Panels>
</Tab.Group>
</FormProvider>
{isFinalStep &&
initialSyncBehavior === SecretSyncInitialSyncBehavior.OverwriteDestination && (
<Checkbox
id="confirm-overwrite"
isChecked={confirmOverwrite}
containerClassName="-mt-5"
onCheckedChange={(isChecked) => setConfirmOverwrite(Boolean(isChecked))}
>
<p
className={`mt-5 text-wrap text-xs ${confirmOverwrite ? "text-mineshaft-200" : "text-red"}`}
>
I understand all secrets present in the configured {destinationName} destination will
be removed if they are not present within Infisical.
</p>
</Checkbox>
)}
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button
isDisabled={
isFinalStep &&
initialSyncBehavior === SecretSyncInitialSyncBehavior.OverwriteDestination &&
!confirmOverwrite
}
onClick={handleNext}
colorSchema="secondary"
>
<Button onClick={handleNext} colorSchema="secondary">
{isFinalStep ? "Create Sync" : "Next"}
</Button>
{selectedTabIndex > 0 && (

View File

@ -8,10 +8,10 @@ import { Button, ModalClose } from "@app/components/v2";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync, useUpdateSecretSync } from "@app/hooks/api/secretSyncs";
import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields/SecretSyncOptionsFields";
import { TSecretSyncForm, UpdateSecretSyncFormSchema } from "./schemas";
import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields";
import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields";
import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields";
import { SecretSyncSourceFields } from "./SecretSyncSourceFields";
type Props = {

View File

@ -8,13 +8,17 @@ import { TSecretSyncForm } from "../schemas";
import { AwsRegionSelect } from "./shared";
export const AwsParameterStoreSyncFields = () => {
const { control } = useFormContext<
const { control, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSParameterStore }
>();
return (
<>
<SecretSyncConnectionField />
<SecretSyncConnectionField
onChange={() => {
setValue("syncOptions.keyId", undefined);
}}
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Region">

View File

@ -9,7 +9,7 @@ import { TSecretSyncForm } from "../schemas";
import { AwsRegionSelect } from "./shared";
export const AwsSecretsManagerSyncFields = () => {
const { control, watch } = useFormContext<
const { control, watch, setValue } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSSecretsManager }
>();
@ -59,7 +59,10 @@ export const AwsSecretsManagerSyncFields = () => {
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
onValueChange={(val) => {
onChange(val);
setValue("syncOptions.syncSecretMetadataAsTags", false);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select an option..."

View File

@ -0,0 +1,209 @@
import { Fragment } from "react";
import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { faPlus, faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
Input,
Switch,
Tooltip
} from "@app/components/v2";
import {
TAwsConnectionKmsKey,
useListAwsConnectionKmsKeys
} from "@app/hooks/api/appConnections/aws";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "../schemas";
export const AwsParameterStoreSyncOptionsFields = () => {
const { control, watch } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSParameterStore }
>();
const region = watch("destinationConfig.region");
const connectionId = useWatch({ name: "connection.id", control });
const { data: kmsKeys = [], isPending: isKmsKeysPending } = useListAwsConnectionKmsKeys(
{
connectionId,
region,
destination: SecretSync.AWSParameterStore
},
{ enabled: Boolean(connectionId && region) }
);
const tagFields = useFieldArray({
control,
name: "syncOptions.tags"
});
return (
<>
<Controller
name="syncOptions.keyId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="The AWS KMS key to encrypt parameters with"
isError={Boolean(error)}
errorText={error?.message}
label="KMS Key"
>
<FilterableSelect
isLoading={isKmsKeysPending && Boolean(connectionId && region)}
isDisabled={!connectionId}
value={kmsKeys.find((org) => org.alias === value) ?? null}
onChange={(option) =>
onChange((option as SingleValue<TAwsConnectionKmsKey>)?.alias ?? null)
}
// eslint-disable-next-line react/no-unstable-nested-components
noOptionsMessage={({ inputValue }) =>
inputValue ? undefined : (
<p>
To configure a KMS key, ensure the following permissions are present on the
selected IAM role:{" "}
<span className="rounded bg-mineshaft-600 text-mineshaft-300">
&#34;kms:ListAliases&#34;
</span>
,{" "}
<span className="rounded bg-mineshaft-600 text-mineshaft-300">
&#34;kms:DescribeKey&#34;
</span>
,{" "}
<span className="rounded bg-mineshaft-600 text-mineshaft-300">
&#34;kms:Encrypt&#34;
</span>
,{" "}
<span className="rounded bg-mineshaft-600 text-mineshaft-300">
&#34;kms:Decrypt&#34;
</span>
.
</p>
)
}
options={kmsKeys}
placeholder="Leave blank to use default KMS key"
getOptionLabel={(option) =>
option.alias === "alias/aws/ssm" ? `${option.alias} (Default)` : option.alias
}
getOptionValue={(option) => option.alias}
/>
</FormControl>
)}
/>
<FormLabel
label="Resource Tags"
tooltipText="Add resource tags to parameters synced by Infisical"
/>
<div className="mb-3 grid max-h-[20vh] grid-cols-12 flex-col items-end gap-2 overflow-y-auto">
{tagFields.fields.map(({ id: tagFieldId }, i) => (
<Fragment key={tagFieldId}>
<div className="col-span-5">
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`syncOptions.tags.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input className="text-xs" {...field} />
</FormControl>
)}
/>
</div>
<div className="col-span-6">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
)}
<Controller
control={control}
name={`syncOptions.tags.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input className="text-xs" {...field} />
</FormControl>
)}
/>
</div>
<Tooltip content="Remove tag" position="right">
<IconButton
variant="plain"
ariaLabel="Remove tag"
className="col-span-1 mb-1.5"
colorSchema="danger"
size="xs"
onClick={() => tagFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</Fragment>
))}
</div>
<div className="mt-2 flex">
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => tagFields.append({ key: "", value: "" })}
>
Add Tag
</Button>
</div>
<Controller
name="syncOptions.syncSecretMetadataAsTags"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="mt-6"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="overwrite-existing-secrets"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
<p className="w-[18rem]">
Sync Secret Metadata as Resource Tags{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
If enabled, metadata attached to secrets will be added as resource tags to
parameters synced by Infisical.
</p>
<p className="mt-4">
Manually configured tags from the field above will take precedence over
secret metadata when tag keys conflict.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
</>
);
};

View File

@ -0,0 +1,208 @@
import { Fragment } from "react";
import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form";
import { SingleValue } from "react-select";
import { faPlus, faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
Input,
Switch,
Tooltip
} from "@app/components/v2";
import {
TAwsConnectionKmsKey,
useListAwsConnectionKmsKeys
} from "@app/hooks/api/appConnections/aws";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { AwsSecretsManagerSyncMappingBehavior } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync";
import { TSecretSyncForm } from "../schemas";
export const AwsSecretsManagerSyncOptionsFields = () => {
const { control, watch } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSSecretsManager }
>();
const region = watch("destinationConfig.region");
const connectionId = useWatch({ name: "connection.id", control });
const mappingBehavior = watch("destinationConfig.mappingBehavior");
const { data: kmsKeys = [], isPending: isKmsKeysPending } = useListAwsConnectionKmsKeys(
{
connectionId,
region,
destination: SecretSync.AWSSecretsManager
},
{ enabled: Boolean(connectionId && region) }
);
const tagFields = useFieldArray({
control,
name: "syncOptions.tags"
});
return (
<>
<Controller
name="syncOptions.keyId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="The AWS KMS key to encrypt secrets with"
isError={Boolean(error)}
errorText={error?.message}
label="KMS Key"
>
<FilterableSelect
isLoading={isKmsKeysPending && Boolean(connectionId && region)}
isDisabled={!connectionId}
value={kmsKeys.find((org) => org.alias === value) ?? null}
onChange={(option) =>
onChange((option as SingleValue<TAwsConnectionKmsKey>)?.alias ?? null)
}
// eslint-disable-next-line react/no-unstable-nested-components
noOptionsMessage={({ inputValue }) =>
inputValue ? undefined : (
<p>
To configure a KMS key, ensure the following permissions are present on the
selected IAM role:{" "}
<span className="rounded bg-mineshaft-600 text-mineshaft-300">
&#34;kms:ListAliases&#34;
</span>
,{" "}
<span className="rounded bg-mineshaft-600 text-mineshaft-300">
&#34;kms:DescribeKey&#34;
</span>
,{" "}
<span className="rounded bg-mineshaft-600 text-mineshaft-300">
&#34;kms:Encrypt&#34;
</span>
,{" "}
<span className="rounded bg-mineshaft-600 text-mineshaft-300">
&#34;kms:Decrypt&#34;
</span>
.
</p>
)
}
options={kmsKeys}
placeholder="Leave blank to use default KMS key"
getOptionLabel={(option) =>
option.alias === "alias/aws/secretsmanager"
? `${option.alias} (Default)`
: option.alias
}
getOptionValue={(option) => option.alias}
/>
</FormControl>
)}
/>
<FormLabel label="Tags" tooltipText="Add tags to secrets synced by Infisical" />
<div className="mb-3 grid max-h-[20vh] grid-cols-12 flex-col items-end gap-2 overflow-y-auto">
{tagFields.fields.map(({ id: tagFieldId }, i) => (
<Fragment key={tagFieldId}>
<div className="col-span-5">
{i === 0 && <span className="text-xs text-mineshaft-400">Key</span>}
<Controller
control={control}
name={`syncOptions.tags.${i}.key`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input className="text-xs" {...field} />
</FormControl>
)}
/>
</div>
<div className="col-span-6">
{i === 0 && (
<FormLabel label="Value" className="text-xs text-mineshaft-400" isOptional />
)}
<Controller
control={control}
name={`syncOptions.tags.${i}.value`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Input className="text-xs" {...field} />
</FormControl>
)}
/>
</div>
<Tooltip content="Remove tag" position="right">
<IconButton
variant="plain"
ariaLabel="Remove tag"
className="col-span-1 mb-1.5"
colorSchema="danger"
size="xs"
onClick={() => tagFields.remove(i)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</Fragment>
))}
</div>
<div className="mb-6 mt-2 flex">
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => tagFields.append({ key: "", value: "" })}
>
Add Tag
</Button>
</div>
{mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne && (
<Controller
name="syncOptions.syncSecretMetadataAsTags"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error?.message)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/80"
id="overwrite-existing-secrets"
thumbClassName="bg-mineshaft-800"
isChecked={value}
onCheckedChange={onChange}
>
<p className="w-[14rem]">
Sync Secret Metadata as Tags{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
If enabled, metadata attached to secrets will be added as tags to secrets
synced by Infisical.
</p>
<p className="mt-4">
Manually configured tags from the field above will take precedence over
secret metadata when tag keys conflict.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
</>
);
};

View File

@ -1,12 +1,15 @@
import { ReactNode } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { FormControl, Select, SelectItem } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { useSecretSyncOption } from "@app/hooks/api/secretSyncs";
import { SecretSync, useSecretSyncOption } from "@app/hooks/api/secretSyncs";
import { TSecretSyncForm } from "./schemas";
import { TSecretSyncForm } from "../schemas";
import { AwsParameterStoreSyncOptionsFields } from "./AwsParameterStoreSyncOptionsFields";
import { AwsSecretsManagerSyncOptionsFields } from "./AwsSecretsManagerSyncOptionsFields";
type Props = {
hideInitialSync?: boolean;
@ -21,6 +24,26 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
const { syncOption } = useSecretSyncOption(destination);
let AdditionalSyncOptionsFieldsComponent: ReactNode;
switch (destination) {
case SecretSync.AWSParameterStore:
AdditionalSyncOptionsFieldsComponent = <AwsParameterStoreSyncOptionsFields />;
break;
case SecretSync.AWSSecretsManager:
AdditionalSyncOptionsFieldsComponent = <AwsSecretsManagerSyncOptionsFields />;
break;
case SecretSync.GitHub:
case SecretSync.GCPSecretManager:
case SecretSync.AzureKeyVault:
case SecretSync.AzureAppConfiguration:
case SecretSync.Databricks:
AdditionalSyncOptionsFieldsComponent = null;
break;
default:
throw new Error(`Unhandled Additional Sync Options Fields: ${destination}`);
}
return (
<>
<p className="mb-4 text-sm text-bunker-300">Configure how secrets should be synced.</p>
@ -91,6 +114,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
)}
</>
)}
{AdditionalSyncOptionsFieldsComponent}
{/* <Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl

View File

@ -0,0 +1 @@
export * from "./SecretSyncOptionsFields";

View File

@ -1,17 +1,71 @@
import { useFormContext } from "react-hook-form";
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const AwsParameterStoreSyncReviewFields = () => {
export const AwsParameterStoreSyncOptionsReviewFields = () => {
const { watch } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSParameterStore }
>();
const [region, path] = watch(["destinationConfig.region", "destinationConfig.path"]);
const [{ keyId, tags, syncSecretMetadataAsTags }] = watch(["syncOptions"]);
return (
<>
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
{tags && tags.length > 0 && (
<SecretSyncLabel label="Resource Tags">
<Tooltip
side="right"
className="max-w-xl p-1"
content={
<Table>
<THead>
<Th className="whitespace-nowrap p-2">Key</Th>
<Th className="p-2">Value</Th>
</THead>
<TBody>
{tags.map((tag) => (
<Tr key={tag.key}>
<Td className="p-2">{tag.key}</Td>
<Td className="p-2">{tag.value}</Td>
</Tr>
))}
</TBody>
</Table>
}
>
<div className="w-min">
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faEye} />
<span>
{tags.length} Tag{tags.length > 1 ? "s" : ""}
</span>
</Badge>
</div>
</Tooltip>
</SecretSyncLabel>
)}
{syncSecretMetadataAsTags && (
<SecretSyncLabel label="Sync Secret Metadata as Resource Tags">
<Badge variant="success">Enabled</Badge>
</SecretSyncLabel>
)}
</>
);
};
export const AwsParameterStoreDestinationReviewFields = () => {
const { watch } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSParameterStore }
>();
const [{ region, path }] = watch(["destinationConfig"]);
const awsRegion = AWS_REGIONS.find((r) => r.slug === region);

View File

@ -1,8 +1,10 @@
import { useFormContext } from "react-hook-form";
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v2";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { AWS_REGIONS } from "@app/helpers/appConnections";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { AwsSecretsManagerSyncMappingBehavior } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync";
@ -37,3 +39,55 @@ export const AwsSecretsManagerSyncReviewFields = () => {
</>
);
};
export const AwsSecretsManagerSyncOptionsReviewFields = () => {
const { watch } = useFormContext<
TSecretSyncForm & { destination: SecretSync.AWSSecretsManager }
>();
const [{ keyId, tags, syncSecretMetadataAsTags }] = watch(["syncOptions"]);
return (
<>
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
{tags && tags.length > 0 && (
<SecretSyncLabel label="Tags">
<Tooltip
side="right"
className="max-w-xl p-1"
content={
<Table>
<THead>
<Th className="whitespace-nowrap p-2">Key</Th>
<Th className="p-2">Value</Th>
</THead>
<TBody>
{tags.map((tag) => (
<Tr key={tag.key}>
<Td className="p-2">{tag.key}</Td>
<Td className="p-2">{tag.value}</Td>
</Tr>
))}
</TBody>
</Table>
}
>
<div className="w-min">
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faEye} />
<span>
{tags.length} Tag{tags.length > 1 ? "s" : ""}
</span>
</Badge>
</div>
</Tooltip>
</SecretSyncLabel>
)}
{syncSecretMetadataAsTags && (
<SecretSyncLabel label="Sync Secret Metadata as Resource Tags">
<Badge variant="success">Enabled</Badge>
</SecretSyncLabel>
)}
</>
);
};

View File

@ -3,15 +3,21 @@ import { useFormContext } from "react-hook-form";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { AwsSecretsManagerSyncReviewFields } from "@app/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields";
import { DatabricksSyncReviewFields } from "@app/components/secret-syncs/forms/SecretSyncReviewFields/DatabricksSyncReviewFields";
import { Badge } from "@app/components/v2";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields";
import {
AwsParameterStoreDestinationReviewFields,
AwsParameterStoreSyncOptionsReviewFields
} from "./AwsParameterStoreSyncReviewFields";
import {
AwsSecretsManagerSyncOptionsReviewFields,
AwsSecretsManagerSyncReviewFields
} from "./AwsSecretsManagerSyncReviewFields";
import { AzureAppConfigurationSyncReviewFields } from "./AzureAppConfigurationSyncReviewFields";
import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields";
import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields";
import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
@ -19,6 +25,7 @@ export const SecretSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm>();
let DestinationFieldsComponent: ReactNode;
let AdditionalSyncOptionsFieldsComponent: ReactNode;
const {
name,
@ -38,10 +45,12 @@ export const SecretSyncReviewFields = () => {
switch (destination) {
case SecretSync.AWSParameterStore:
DestinationFieldsComponent = <AwsParameterStoreSyncReviewFields />;
DestinationFieldsComponent = <AwsParameterStoreDestinationReviewFields />;
AdditionalSyncOptionsFieldsComponent = <AwsParameterStoreSyncOptionsReviewFields />;
break;
case SecretSync.AWSSecretsManager:
DestinationFieldsComponent = <AwsSecretsManagerSyncReviewFields />;
AdditionalSyncOptionsFieldsComponent = <AwsSecretsManagerSyncOptionsReviewFields />;
break;
case SecretSync.GitHub:
DestinationFieldsComponent = <GitHubSyncReviewFields />;
@ -84,7 +93,7 @@ export const SecretSyncReviewFields = () => {
</div>
<div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Options</span>
<span className="text-sm text-mineshaft-300">Sync Options</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<SecretSyncLabel label="Auto-Sync">
@ -97,6 +106,7 @@ export const SecretSyncReviewFields = () => {
</SecretSyncLabel>
{/* <SecretSyncLabel label="Prepend Prefix">{prependPrefix}</SecretSyncLabel>
<SecretSyncLabel label="Append Suffix">{appendSuffix}</SecretSyncLabel> */}
{AdditionalSyncOptionsFieldsComponent}
</div>
</div>
<div className="flex flex-col gap-3">

View File

@ -1,16 +1,45 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const AwsParameterStoreSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.AWSParameterStore),
destinationConfig: z.object({
path: z
.string()
.trim()
.min(1, "Parameter Store Path required")
.max(2048, "Cannot exceed 2048 characters")
.regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format'),
region: z.string().min(1, "Region required")
export const AwsParameterStoreSyncDestinationSchema = BaseSecretSyncSchema(
z.object({
keyId: z.string().optional(),
tags: z
.object({
key: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.min(1, "Key required")
.max(128, "Tag key cannot exceed 128 characters"),
value: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.max(256, "Tag value cannot exceed 256 characters")
})
.array()
.max(50)
.optional(),
syncSecretMetadataAsTags: z.boolean().optional()
})
});
).merge(
z.object({
destination: z.literal(SecretSync.AWSParameterStore),
destinationConfig: z.object({
path: z
.string()
.trim()
.min(1, "Parameter Store Path required")
.max(2048, "Cannot exceed 2048 characters")
.regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format'),
region: z.string().min(1, "Region required")
})
})
);

View File

@ -1,30 +1,59 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { AwsSecretsManagerSyncMappingBehavior } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync";
export const AwsSecretsManagerSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.AWSSecretsManager),
destinationConfig: z
.discriminatedUnion("mappingBehavior", [
z.object({
mappingBehavior: z.literal(AwsSecretsManagerSyncMappingBehavior.OneToOne)
}),
z.object({
mappingBehavior: z.literal(AwsSecretsManagerSyncMappingBehavior.ManyToOne),
secretName: z
export const AwsSecretsManagerSyncDestinationSchema = BaseSecretSyncSchema(
z.object({
keyId: z.string().optional(),
tags: z
.object({
key: z
.string()
.regex(
/^[a-zA-Z0-9/_+=.@-]+$/,
"Secret name must contain only alphanumeric characters and the characters /_+=.@-"
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.min(1, "Secret name is required")
.max(256, "Secret name cannot exceed 256 characters")
.min(1, "Key required")
.max(128, "Tag key cannot exceed 128 characters"),
value: z
.string()
.regex(
/^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u,
"Values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-"
)
.max(256, "Tag value cannot exceed 256 characters")
})
])
.and(
z.object({
region: z.string().min(1, "Region required")
})
)
});
.array()
.max(50)
.optional(),
syncSecretMetadataAsTags: z.boolean().optional()
})
).merge(
z.object({
destination: z.literal(SecretSync.AWSSecretsManager),
destinationConfig: z
.discriminatedUnion("mappingBehavior", [
z.object({
mappingBehavior: z.literal(AwsSecretsManagerSyncMappingBehavior.OneToOne)
}),
z.object({
mappingBehavior: z.literal(AwsSecretsManagerSyncMappingBehavior.ManyToOne),
secretName: z
.string()
.regex(
/^[a-zA-Z0-9/_+=.@-]+$/,
"Secret name must contain only alphanumeric characters and the characters /_+=.@-"
)
.min(1, "Secret name is required")
.max(256, "Secret name cannot exceed 256 characters")
})
])
.and(
z.object({
region: z.string().min(1, "Region required")
})
)
})
);

View File

@ -1,19 +1,22 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const AzureAppConfigurationSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.AzureAppConfiguration),
destinationConfig: z.object({
configurationUrl: z
.string()
.trim()
.min(1, { message: "Azure App Configuration URL is required" })
.url()
.refine(
(val) => val.endsWith(".azconfig.io"),
"URL should have the following format: https://resource-name-here.azconfig.io"
),
label: z.string().optional()
export const AzureAppConfigurationSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.AzureAppConfiguration),
destinationConfig: z.object({
configurationUrl: z
.string()
.trim()
.min(1, { message: "Azure App Configuration URL is required" })
.url()
.refine(
(val) => val.endsWith(".azconfig.io"),
"URL should have the following format: https://resource-name-here.azconfig.io"
),
label: z.string().optional()
})
})
});
);

View File

@ -1,10 +1,16 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const AzureKeyVaultSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.AzureKeyVault),
destinationConfig: z.object({
vaultBaseUrl: z.string().url("Invalid vault base URL format").min(1, "Vault base URL required")
export const AzureKeyVaultSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.AzureKeyVault),
destinationConfig: z.object({
vaultBaseUrl: z
.string()
.url("Invalid vault base URL format")
.min(1, "Vault base URL required")
})
})
});
);

View File

@ -0,0 +1,39 @@
import { AnyZodObject, z } from "zod";
import { SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs";
import { slugSchema } from "@app/lib/schemas";
export const BaseSecretSyncSchema = <T extends AnyZodObject | undefined = undefined>(
additionalSyncOptions?: T
) => {
const baseSyncOptionsSchema = z.object({
initialSyncBehavior: z.nativeEnum(SecretSyncInitialSyncBehavior)
// scott: removed temporarily for evaluation of template formatting
// prependPrefix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional(),
// appendSuffix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional()
});
const syncOptionsSchema = additionalSyncOptions
? baseSyncOptionsSchema.merge(additionalSyncOptions)
: (baseSyncOptionsSchema as T extends AnyZodObject
? z.ZodObject<z.objectUtil.MergeShapes<typeof baseSyncOptionsSchema.shape, T["shape"]>>
: typeof baseSyncOptionsSchema);
return z.object({
name: slugSchema({ field: "Name" }),
description: z.string().trim().max(256, "Cannot exceed 256 characters").optional(),
connection: z.object({ name: z.string(), id: z.string().uuid() }),
environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }),
secretPath: z.string().min(1, "Secret path required"),
syncOptions: syncOptionsSchema,
isAutoSyncEnabled: z.boolean()
});
};

View File

@ -1,10 +1,13 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
export const DatabricksSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.Databricks),
destinationConfig: z.object({
scope: z.string().trim().min(1, "Databricks scope required")
export const DatabricksSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.Databricks),
destinationConfig: z.object({
scope: z.string().trim().min(1, "Databricks scope required")
})
})
});
);

View File

@ -1,12 +1,15 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync";
export const GcpSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.GCPSecretManager),
destinationConfig: z.object({
scope: z.literal(GcpSyncScope.Global),
projectId: z.string().min(1, "Project ID required")
export const GcpSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.GCPSecretManager),
destinationConfig: z.object({
scope: z.literal(GcpSyncScope.Global),
projectId: z.string().min(1, "Project ID required")
})
})
});
);

View File

@ -1,45 +1,48 @@
import { z } from "zod";
import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema";
import { SecretSync } from "@app/hooks/api/secretSyncs";
import {
GitHubSyncScope,
GitHubSyncVisibility
} from "@app/hooks/api/secretSyncs/types/github-sync";
export const GitHubSyncDestinationSchema = z.object({
destination: z.literal(SecretSync.GitHub),
destinationConfig: z
.discriminatedUnion("scope", [
z.object({
scope: z.literal(GitHubSyncScope.Organization),
org: z.string().min(1, "Organization name required"),
visibility: z.nativeEnum(GitHubSyncVisibility),
selectedRepositoryIds: z.number().array().optional()
}),
z.object({
scope: z.literal(GitHubSyncScope.Repository),
owner: z.string().min(1, "Repository owner name required"),
repo: z.string().min(1, "Repository name required")
}),
z.object({
scope: z.literal(GitHubSyncScope.RepositoryEnvironment),
owner: z.string().min(1, "Repository owner name required"),
repo: z.string().min(1, "Repository name required"),
env: z.string().min(1, "Environment name required")
})
])
.superRefine((options, ctx) => {
if (options.scope === GitHubSyncScope.Organization) {
if (
options.visibility === GitHubSyncVisibility.Selected &&
!options.selectedRepositoryIds?.length
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Select at least 1 repository",
path: ["selectedRepositoryIds"]
});
export const GitHubSyncDestinationSchema = BaseSecretSyncSchema().merge(
z.object({
destination: z.literal(SecretSync.GitHub),
destinationConfig: z
.discriminatedUnion("scope", [
z.object({
scope: z.literal(GitHubSyncScope.Organization),
org: z.string().min(1, "Organization name required"),
visibility: z.nativeEnum(GitHubSyncVisibility),
selectedRepositoryIds: z.number().array().optional()
}),
z.object({
scope: z.literal(GitHubSyncScope.Repository),
owner: z.string().min(1, "Repository owner name required"),
repo: z.string().min(1, "Repository name required")
}),
z.object({
scope: z.literal(GitHubSyncScope.RepositoryEnvironment),
owner: z.string().min(1, "Repository owner name required"),
repo: z.string().min(1, "Repository name required"),
env: z.string().min(1, "Environment name required")
})
])
.superRefine((options, ctx) => {
if (options.scope === GitHubSyncScope.Organization) {
if (
options.visibility === GitHubSyncVisibility.Selected &&
!options.selectedRepositoryIds?.length
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Select at least 1 repository",
path: ["selectedRepositoryIds"]
});
}
}
}
})
});
})
})
);

View File

@ -3,37 +3,12 @@ import { z } from "zod";
import { AwsSecretsManagerSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/aws-secrets-manager-sync-destination-schema";
import { DatabricksSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/databricks-sync-destination-schema";
import { GitHubSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/github-sync-destination-schema";
import { SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs";
import { slugSchema } from "@app/lib/schemas";
import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema";
import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema";
import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema";
import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema";
const BaseSecretSyncSchema = z.object({
name: slugSchema({ field: "Name" }),
description: z.string().trim().max(256, "Cannot exceed 256 characters").optional(),
connection: z.object({ name: z.string(), id: z.string().uuid() }),
environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }),
secretPath: z.string().min(1, "Secret path required"),
syncOptions: z.object({
initialSyncBehavior: z.nativeEnum(SecretSyncInitialSyncBehavior)
// scott: removed temporarily for evaluation of template formatting
// prependPrefix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional(),
// appendSuffix: z
// .string()
// .trim()
// .transform((str) => str.toUpperCase())
// .optional()
}),
isAutoSyncEnabled: z.boolean()
});
const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
AwsParameterStoreSyncDestinationSchema,
AwsSecretsManagerSyncDestinationSchema,
@ -44,8 +19,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [
DatabricksSyncDestinationSchema
]);
export const SecretSyncFormSchema = SecretSyncUnionSchema.and(BaseSecretSyncSchema);
export const SecretSyncFormSchema = SecretSyncUnionSchema;
export const UpdateSecretSyncFormSchema = SecretSyncUnionSchema.and(BaseSecretSyncSchema.partial());
export const UpdateSecretSyncFormSchema = SecretSyncUnionSchema;
export type TSecretSyncForm = z.infer<typeof SecretSyncFormSchema>;

View File

@ -20,7 +20,7 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
sideOffset={10}
sideOffset={-8}
{...props}
ref={forwardedRef}
className={twMerge(

View File

@ -0,0 +1,2 @@
export * from "./queries";
export * from "./types";

View File

@ -0,0 +1,42 @@
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { appConnectionKeys } from "@app/hooks/api/appConnections";
import {
TAwsConnectionKmsKey,
TAwsConnectionListKmsKeysResponse,
TListAwsConnectionKmsKeys
} from "./types";
const awsConnectionKeys = {
all: [...appConnectionKeys.all, "aws"] as const,
listKmsKeys: (params: TListAwsConnectionKmsKeys) =>
[...awsConnectionKeys.all, "kms-keys", params] as const
};
export const useListAwsConnectionKmsKeys = (
{ connectionId, ...params }: TListAwsConnectionKmsKeys,
options?: Omit<
UseQueryOptions<
TAwsConnectionKmsKey[],
unknown,
TAwsConnectionKmsKey[],
ReturnType<typeof awsConnectionKeys.listKmsKeys>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: awsConnectionKeys.listKmsKeys({ connectionId, ...params }),
queryFn: async () => {
const { data } = await apiRequest.get<TAwsConnectionListKmsKeysResponse>(
`/api/v1/app-connections/aws/${connectionId}/kms-keys`,
{ params }
);
return data.kmsKeys;
},
...options
});
};

View File

@ -0,0 +1,16 @@
import { SecretSync } from "@app/hooks/api/secretSyncs";
export type TListAwsConnectionKmsKeys = {
connectionId: string;
region: string;
destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager;
};
export type TAwsConnectionKmsKey = {
alias: string;
id: string;
};
export type TAwsConnectionListKmsKeysResponse = {
kmsKeys: TAwsConnectionKmsKey[];
};

View File

@ -1,6 +1,6 @@
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 { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export type TAwsParameterStoreSync = TRootSecretSync & {
destination: SecretSync.AWSParameterStore;
@ -13,4 +13,9 @@ export type TAwsParameterStoreSync = TRootSecretSync & {
name: string;
id: string;
};
syncOptions: RootSyncOptions & {
keyId?: string;
tags?: { key: string; value?: string }[];
syncSecretMetadataAsTags?: boolean;
};
};

View File

@ -1,6 +1,6 @@
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 { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync";
export type TAwsSecretsManagerSync = TRootSecretSync & {
destination: SecretSync.AWSSecretsManager;
@ -19,6 +19,11 @@ export type TAwsSecretsManagerSync = TRootSecretSync & {
name: string;
id: string;
};
syncOptions: RootSyncOptions & {
keyId?: string;
tags?: { key: string; value?: string }[];
syncSecretMetadataAsTags?: boolean;
};
};
export enum AwsSecretsManagerSyncMappingBehavior {
OneToOne = "one-to-one",

View File

@ -1,6 +1,12 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { SecretSyncInitialSyncBehavior, SecretSyncStatus } from "@app/hooks/api/secretSyncs";
export type RootSyncOptions = {
initialSyncBehavior: SecretSyncInitialSyncBehavior;
// prependPrefix?: string;
// appendSuffix?: string;
};
export type TRootSecretSync = {
id: string;
name: string;
@ -24,11 +30,7 @@ export type TRootSecretSync = {
lastRemoveJobId: string | null;
lastRemovedAt: Date | null;
lastRemoveMessage: string | null;
syncOptions: {
initialSyncBehavior: SecretSyncInitialSyncBehavior;
// prependPrefix?: string;
// appendSuffix?: string;
};
syncOptions: RootSyncOptions;
connection: {
app: AppConnection;
id: string;

View File

@ -20,44 +20,46 @@ export const MenuIconButton = <T extends ElementType = "button">({
ComponentPropsWithRef<T> & { lottieIconMode?: "reverse" | "forward" }): JSX.Element => {
const iconRef = useRef<DotLottie | null>(null);
return (
<Item
type="button"
role="menuitem"
className={twMerge(
"group relative flex w-full cursor-pointer flex-col items-center justify-center rounded p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700",
isSelected && "bg-bunker-800 hover:bg-mineshaft-600",
isDisabled && "cursor-not-allowed hover:bg-transparent",
className
)}
onMouseEnter={() => iconRef.current?.play()}
onMouseLeave={() => iconRef.current?.stop()}
ref={inputRef}
{...props}
>
<div
className={`${
isSelected ? "opacity-100" : "opacity-0"
} absolute -left-[0.28rem] h-full w-1 rounded-md bg-primary transition-all duration-150`}
/>
{icon && (
<div className="my-auto mb-2 h-6 w-6">
<DotLottieReact
dotLottieRefCallback={(el) => {
iconRef.current = el;
}}
src={`/lotties/${icon}.json`}
loop
className="h-full w-full"
mode={lottieIconMode}
/>
</div>
)}
<div
className="flex-grow justify-center break-words text-center"
style={{ fontSize: "10px" }}
<div className={!isSelected ? "hover:px-1" : ""}>
<Item
type="button"
role="menuitem"
className={twMerge(
"group relative flex w-full cursor-pointer flex-col items-center justify-center rounded my-1 p-2 font-inter text-sm text-bunker-100 transition-all duration-150 hover:bg-mineshaft-700",
isSelected && "bg-bunker-800 hover:bg-mineshaft-600 rounded-none",
isDisabled && "cursor-not-allowed hover:bg-transparent",
className
)}
onMouseEnter={() => iconRef.current?.play()}
onMouseLeave={() => iconRef.current?.stop()}
ref={inputRef}
{...props}
>
{children}
</div>
</Item>
<div
className={`${
isSelected ? "opacity-100" : "opacity-0"
} absolute left-0 h-full w-1 bg-primary transition-all duration-150`}
/>
{icon && (
<div className="my-auto mb-2 h-6 w-6">
<DotLottieReact
dotLottieRefCallback={(el) => {
iconRef.current = el;
}}
src={`/lotties/${icon}.json`}
loop
className="h-full w-full"
mode={lottieIconMode}
/>
</div>
)}
<div
className="flex-grow justify-center break-words text-center"
style={{ fontSize: "10px" }}
>
{children}
</div>
</Item>
</div>
);
};

View File

@ -84,6 +84,10 @@ export const MinimizedOrgSidebar = () => {
const [requiredMfaMethod, setRequiredMfaMethod] = useState(MfaMethod.EMAIL);
const [mfaSuccessCallback, setMfaSuccessCallback] = useState<() => void>(() => {});
const { subscription } = useSubscription();
const [open, setOpen] = useState(false);
const [openSupport, setOpenSupport] = useState(false);
const [openUser, setOpenUser] = useState(false);
const [openOrg, setOpenOrg] = useState(false);
const { user } = useUser();
const { mutateAsync } = useGetOrgTrialUrl();
@ -160,23 +164,29 @@ export const MinimizedOrgSidebar = () => {
>
<nav className="items-between flex h-full flex-col justify-between overflow-y-auto dark:[color-scheme:dark]">
<div>
<div className="flex cursor-pointer items-center p-2 pt-4 hover:bg-mineshaft-700">
<DropdownMenu modal>
<DropdownMenuTrigger asChild>
<div className="flex w-full items-center justify-center rounded-md border border-none border-mineshaft-600 p-1 transition-all">
<div className="flex items-center hover:bg-mineshaft-700">
<DropdownMenu open={openOrg} onOpenChange={setOpenOrg} modal>
<DropdownMenuTrigger
onMouseEnter={() => setOpenOrg(true)}
onMouseLeave={() => setOpenOrg(false)}
asChild
>
<div className="flex w-full items-center justify-center rounded-md border border-none border-mineshaft-600 p-3 pb-5 pt-6 transition-all">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary">
{currentOrg?.name.charAt(0)}
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
onMouseEnter={() => setOpenOrg(true)}
onMouseLeave={() => setOpenOrg(false)}
align="start"
side="right"
className="p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "320px" }}
className="mt-6 cursor-default p-1 shadow-mineshaft-600 drop-shadow-md"
style={{ minWidth: "220px" }}
>
<div className="px-2 py-1">
<div className="flex w-full items-center justify-center rounded-md border border-mineshaft-600 p-1 transition-all duration-150 hover:bg-mineshaft-700">
<div className="px-0.5 py-1">
<div className="flex w-full items-center justify-center rounded-md border border-mineshaft-600 bg-gradient-to-tr from-primary-500/5 to-mineshaft-800 p-1 transition-all duration-300">
<div className="mr-2 flex h-8 w-8 items-center justify-center rounded-md bg-primary text-black">
{currentOrg?.name.charAt(0)}
</div>
@ -241,7 +251,7 @@ export const MinimizedOrgSidebar = () => {
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="space-y-1 px-1">
<div className="space-y-1">
<Link to="/organization/secret-manager/overview">
{({ isActive }) => (
<MenuIconButton
@ -251,7 +261,7 @@ export const MinimizedOrgSidebar = () => {
}
icon="sliding-carousel"
>
Secret Manager
Secrets
</MenuIconButton>
)}
</Link>
@ -264,7 +274,7 @@ export const MinimizedOrgSidebar = () => {
}
icon="note"
>
Cert Manager
PKI
</MenuIconButton>
)}
</Link>
@ -296,31 +306,41 @@ export const MinimizedOrgSidebar = () => {
<Link to="/organization/secret-scanning">
{({ isActive }) => (
<MenuIconButton isSelected={isActive} icon="secret-scan">
Secret Scanning
Scanner
</MenuIconButton>
)}
</Link>
<Link to="/organization/secret-sharing">
{({ isActive }) => (
<MenuIconButton isSelected={isActive} icon="lock-closed">
Secret Sharing
Share
</MenuIconButton>
)}
</Link>
<div className="my-1 w-full bg-mineshaft-500" style={{ height: "1px" }} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
asChild
>
<div className="w-full">
<MenuIconButton
lottieIconMode="reverse"
icon="settings-cog"
isSelected={isMoreSelected}
>
Org Controls
Admin
</MenuIconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right" className="p-1">
<DropdownMenuContent
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
align="start"
side="right"
className="p-1"
>
<DropdownMenuLabel>Organization Options</DropdownMenuLabel>
<Link to="/organization/access-management">
<DropdownMenuItem icon={<FontAwesomeIcon className="w-3" icon={faUsers} />}>
@ -364,14 +384,24 @@ export const MinimizedOrgSidebar = () => {
: "mb-4"
} flex w-full cursor-default flex-col items-center px-1 text-sm text-mineshaft-400`}
>
<DropdownMenu>
<DropdownMenuTrigger className="w-full">
<DropdownMenu open={openSupport} onOpenChange={setOpenSupport}>
<DropdownMenuTrigger
onMouseEnter={() => setOpenSupport(true)}
onMouseLeave={() => setOpenSupport(false)}
className="w-full"
>
<MenuIconButton>
<FontAwesomeIcon icon={faInfoCircle} className="mb-3 text-lg" />
Support
</MenuIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<DropdownMenuContent
onMouseEnter={() => setOpenSupport(true)}
onMouseLeave={() => setOpenSupport(false)}
align="end"
side="right"
className="p-1"
>
{INFISICAL_SUPPORT_OPTIONS.map(([icon, text, url]) => (
<DropdownMenuItem key={url as string}>
<a
@ -419,17 +449,28 @@ export const MinimizedOrgSidebar = () => {
</button>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger className="w-full" asChild>
<DropdownMenu open={openUser} onOpenChange={setOpenUser}>
<DropdownMenuTrigger
onMouseEnter={() => setOpenUser(true)}
onMouseLeave={() => setOpenUser(false)}
className="w-full"
asChild
>
<div>
<MenuIconButton icon="user">User</MenuIconButton>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<div className="px-2 py-1">
<div className="flex w-full items-center justify-center rounded-md border border-mineshaft-600 p-1 transition-all duration-150 hover:bg-mineshaft-700">
<div className="p-2">
<FontAwesomeIcon icon={faUser} className="text-mineshaft-400" />
<DropdownMenuContent
onMouseEnter={() => setOpenUser(true)}
onMouseLeave={() => setOpenUser(false)}
side="right"
align="end"
className="p-1"
>
<div className="cursor-default px-1 py-1">
<div className="flex w-full items-center justify-center rounded-md border border-mineshaft-600 bg-gradient-to-tr from-primary-500/10 to-mineshaft-800 p-1 px-2 transition-all duration-150">
<div className="p-1 pr-3">
<FontAwesomeIcon icon={faUser} className="text-xl text-mineshaft-400" />
</div>
<div className="flex flex-grow flex-col text-white">
<div className="max-w-36 truncate text-ellipsis text-sm font-medium capitalize">
@ -477,11 +518,11 @@ export const MinimizedOrgSidebar = () => {
</DropdownMenuItem>
</Link>
)}
<Link to="/organization/admin">
<DropdownMenuItem className="mt-1 border-t border-mineshaft-600">
Organization Admin Console
</DropdownMenuItem>
</Link>
<div className="mt-1 border-t border-mineshaft-600 pt-1">
<Link to="/organization/admin">
<DropdownMenuItem>Organization Admin Console</DropdownMenuItem>
</Link>
</div>
<div className="mt-1 h-1 border-t border-mineshaft-600" />
<DropdownMenuItem onClick={logOutUser} icon={<FontAwesomeIcon icon={faSignOut} />}>
Log Out

View File

@ -120,7 +120,7 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="API Key" type="text" />
<Input {...field} placeholder="API Key" type="text" autoComplete="off" autoCorrect="off" spellCheck="false" />
</FormControl>
)}
/>
@ -155,7 +155,7 @@ export const ShareSecretForm = ({ isPublic, value }: Props) => {
errorText={error?.message}
isOptional
>
<Input {...field} placeholder="Password" type="password" />
<Input {...field} placeholder="Password" type="password" autoComplete="new-password" autoCorrect="off" spellCheck="false" aria-autocomplete="none" data-form-type="other" />
</FormControl>
)}
/>

View File

@ -19,7 +19,7 @@ export const SecretSyncsTab = () => {
const { data: secretSyncs = [], isPending: isSecretSyncsPending } = useListSecretSyncs(
currentWorkspace.id,
{
refetchInterval: 4000
refetchInterval: 30000
}
);

View File

@ -59,7 +59,12 @@ export const PitDrawer = ({
onClick={() => onSelectSnapshot(id)}
>
<div className="flex w-full justify-between">
<div>{formatDistance(new Date(createdAt), new Date())}</div>
<div>
{(() => {
const distance = formatDistance(new Date(createdAt), new Date());
return distance.charAt(0).toUpperCase() + distance.slice(1) + " ago";
})()}
</div>
<div>{getButtonLabel(i === 0 && index === 0, snapshotId === id)}</div>
</div>
</Button>
@ -70,7 +75,7 @@ export const PitDrawer = ({
<Button
className="mt-8 px-4 py-3 text-sm"
isFullWidth
variant="star"
variant="outline_bg"
isLoading={isFetchingNextPage}
isDisabled={isFetchingNextPage || !hasNextPage}
onClick={fetchNextPage}

View File

@ -1,11 +1,11 @@
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCircleQuestion } from "@fortawesome/free-regular-svg-icons";
import { faCircleQuestion, faEye } from "@fortawesome/free-regular-svg-icons";
import {
faArrowRotateRight,
faCheckCircle,
faCircle,
faCircleDot,
faClock,
faEyeSlash,
faPlus,
faShare,
faTag,
@ -236,44 +236,102 @@ export const SecretDetailSidebar = ({
}}
isOpen={isOpen}
>
<DrawerContent title="Secret">
<DrawerContent title={`Secret ${secret?.key}`} className="thin-scrollbar">
<form onSubmit={handleSubmit(handleFormSubmit)} className="h-full">
<div className="flex h-full flex-col">
<FormControl label="Key">
<Input isDisabled {...register("key")} />
</FormControl>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<div className="flex flex-row">
<div className="w-full">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<Controller
name="value"
key="secret-value"
control={control}
render={({ field }) => (
<FormControl label="Value">
<InfisicalSecretInput
isReadOnly={isReadOnly}
environment={environment}
secretPath={secretPath}
key="secret-value"
isDisabled={isOverridden || !isAllowed}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
autoFocus={false}
/>
</FormControl>
)}
/>
)}
</ProjectPermissionCan>
</div>
<div className="ml-1 mt-1.5 flex items-center">
<Button
className="w-full px-2 py-[0.43rem] font-normal"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faShare} />}
onClick={() => {
const value = secret?.valueOverride ?? secret?.value;
if (value) {
handleSecretShare(value);
}
}}
>
Share
</Button>
</div>
</div>
<div className="mb-2 rounded border border-mineshaft-600 bg-mineshaft-900 p-4 px-0 pb-0">
<div className="mb-4 px-4">
<Controller
name="value"
key="secret-value"
control={control}
render={({ field }) => (
<FormControl label="Value">
<InfisicalSecretInput
isReadOnly={isReadOnly}
environment={environment}
secretPath={secretPath}
key="secret-value"
isDisabled={isOverridden || !isAllowed}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
autoFocus={false}
/>
</FormControl>
name="skipMultilineEncoding"
render={({ field: { value, onChange, onBlur } }) => (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<div className="flex items-center justify-between">
<span className="w-max text-sm text-mineshaft-300">
Multi-line encoding
<Tooltip
content="When enabled, multiline secrets will be handled by escaping newlines and enclosing the entire value in double quotes."
className="z-[100]"
>
<FontAwesomeIcon icon={faCircleQuestion} className="ml-2" />
</Tooltip>
</span>
<Switch
id="skipmultiencoding-option"
onCheckedChange={(isChecked) => onChange(isChecked)}
isChecked={value}
onBlur={onBlur}
isDisabled={!isAllowed}
className="items-center justify-between"
/>
</div>
)}
</ProjectPermissionCan>
)}
/>
)}
</ProjectPermissionCan>
<div className="mb-2 border-b border-mineshaft-600 pb-4">
</div>
<div
className={`mb-4 w-full border-t border-mineshaft-600 ${isOverridden ? "block" : "hidden"}`}
/>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
@ -284,194 +342,243 @@ export const SecretDetailSidebar = ({
})}
>
{(isAllowed) => (
<Switch
isDisabled={!isAllowed}
id="personal-override"
onCheckedChange={handleOverrideClick}
isChecked={isOverridden}
>
Override with a personal value
</Switch>
<div className="flex items-center justify-between px-4 pb-4">
<span className="w-max text-sm text-mineshaft-300">
Override with a personal value
<Tooltip
content="Override the secret value with a personal value that does not get shared with other users and machines."
className="z-[100]"
>
<FontAwesomeIcon icon={faCircleQuestion} className="ml-2" />
</Tooltip>
</span>
<Switch
isDisabled={!isAllowed}
id="personal-override"
onCheckedChange={handleOverrideClick}
isChecked={isOverridden}
className="justify-start"
/>
</div>
)}
</ProjectPermissionCan>
{isOverridden && (
<Controller
name="valueOverride"
control={control}
render={({ field }) => (
<FormControl label="Override Value" className="px-4">
<InfisicalSecretInput
isReadOnly={isReadOnly}
environment={environment}
secretPath={secretPath}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
/>
</FormControl>
)}
/>
)}
</div>
{isOverridden && (
<Controller
name="valueOverride"
control={control}
render={({ field }) => (
<FormControl label="Value Override">
<InfisicalSecretInput
isReadOnly={isReadOnly}
environment={environment}
secretPath={secretPath}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
{...field}
/>
</FormControl>
)}
/>
)}
<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={`secretMetadata.${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={`secretMetadata.${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)}
<div className="mb-4 mt-2 flex flex-col rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4 px-0 pb-0">
<div
className={`flex justify-between px-4 text-mineshaft-100 ${tagFields.fields.length > 0 ? "flex-col" : "flex-row"}`}
>
<div
className={`text-sm text-mineshaft-300 ${tagFields.fields.length > 0 ? "mb-2" : "mt-0.5"}`}
>
Tags
</div>
<div>
<FormControl>
<div
className={`grid auto-cols-min grid-flow-col gap-2 overflow-hidden ${tagFields.fields.length > 0 ? "pt-2" : ""}`}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
))}
<div className="mt-2">
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
variant="outline_bg"
onClick={() => metadataFormFields.append({ key: "", value: "" })}
>
Add Key
</Button>
{tagFields.fields.map(({ tagColor, id: formId, slug, id }) => (
<Tag
className="flex w-min items-center space-x-2"
key={formId}
onClose={() => {
if (cannotEditSecret) {
createNotification({ type: "error", text: "Access denied" });
return;
}
const tag = tags?.find(({ id: tagId }) => id === tagId);
if (tag) handleTagSelect(tag);
}}
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
</Tag>
))}
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add"
variant="outline_bg"
size="xs"
className="rounded-md"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="start" side="right" className="z-[100]">
<DropdownMenuLabel className="pl-2">
Add tags to this secret
</DropdownMenuLabel>
{tags.map((tag) => {
const { id: tagId, slug, color } = tag;
const isSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={tagId}
icon={isSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{slug}
</div>
</DropdownMenuItem>
);
})}
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Tags}
>
{(isAllowed) => (
<div className="p-2">
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faTag} />}
onClick={onCreateTag}
isDisabled={!isAllowed}
>
Create a tag
</Button>
</div>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FormControl>
</div>
</div>
</FormControl>
<FormControl label="Tags" className="">
<div className="grid auto-cols-min grid-flow-col gap-2 overflow-hidden pt-2">
{tagFields.fields.map(({ tagColor, id: formId, slug, id }) => (
<Tag
className="flex w-min items-center space-x-2"
key={formId}
onClose={() => {
if (cannotEditSecret) {
createNotification({ type: "error", text: "Access denied" });
return;
}
const tag = tags?.find(({ id: tagId }) => id === tagId);
if (tag) handleTagSelect(tag);
}}
>
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: tagColor || "#bec2c8" }}
/>
<div className="text-sm">{slug}</div>
</Tag>
))}
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add"
variant="outline_bg"
size="xs"
className="rounded-md"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end" className="z-[100]">
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
{tags.map((tag) => {
const { id: tagId, slug, color } = tag;
<div
className={`mb-4 w-full border-t border-mineshaft-600 ${tagFields.fields.length > 0 || metadataFormFields.fields.length > 0 ? "block" : "hidden"}`}
/>
const isSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={tagId}
icon={isSelected && <FontAwesomeIcon icon={faCheckCircle} />}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
<div
className={`flex justify-between px-4 text-mineshaft-100 ${metadataFormFields.fields.length > 0 ? "flex-col" : "flex-row"}`}
>
<div
className={`text-sm text-mineshaft-300 ${metadataFormFields.fields.length > 0 ? "mb-2" : "mt-0.5"}`}
>
Metadata
</div>
<FormControl>
<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={`secretMetadata.${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
/>
{slug}
</div>
</DropdownMenuItem>
);
})}
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Tags}
>
{(isAllowed) => (
<DropdownMenuItem asChild>
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faTag} />}
onClick={onCreateTag}
isDisabled={!isAllowed}
>
Create a tag
</Button>
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
<Controller
control={control}
name={`secretMetadata.${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>
</div>
</div>
<FormControl label="Comments & Notes">
<TextArea
className="border border-mineshaft-600 bg-bunker-800 text-sm"
{...register("comment")}
readOnly={isReadOnly}
rows={5}
/>
</FormControl>
<FormControl label="Reminder">
<FormControl>
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
<div className="ml-1 mt-2 flex items-center justify-between">
<div className="flex items-center justify-between px-2">
<div className="flex items-center space-x-2">
<FontAwesomeIcon className="text-primary-500" icon={faClock} />
<span className="text-sm text-bunker-300">
@ -490,9 +597,9 @@ export const SecretDetailSidebar = ({
</div>
</div>
) : (
<div className="ml-1 mt-2 flex items-center space-x-2">
<div className="ml-1 flex items-center space-x-2">
<Button
className="w-full px-2 py-1"
className="w-full px-2 py-2 font-normal"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faClock} />}
onClick={() => setCreateReminderFormOpen.on()}
@ -503,92 +610,147 @@ export const SecretDetailSidebar = ({
</div>
)}
</FormControl>
<FormControl label="Comments & Notes">
<TextArea
className="border border-mineshaft-600 text-sm"
{...register("comment")}
readOnly={isReadOnly}
rows={5}
/>
</FormControl>
<div className="my-2 mb-4 border-b border-mineshaft-600 pb-4">
<Controller
control={control}
name="skipMultilineEncoding"
render={({ field: { value, onChange, onBlur } }) => (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<Switch
id="skipmultiencoding-option"
onCheckedChange={(isChecked) => onChange(isChecked)}
isChecked={value}
onBlur={onBlur}
isDisabled={!isAllowed}
className="items-center"
>
Multi line encoding
<Tooltip
content="When enabled, multiline secrets will be handled by escaping newlines and enclosing the entire value in double quotes."
className="z-[100]"
>
<FontAwesomeIcon icon={faCircleQuestion} className="ml-1" size="sm" />
</Tooltip>
</Switch>
)}
</ProjectPermissionCan>
)}
/>
</div>
<div className="ml-1 flex items-center space-x-4">
<Button
className="w-full px-2 py-1"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faShare} />}
onClick={() => {
const value = secret?.valueOverride ?? secret?.value;
if (value) {
handleSecretShare(value);
}
}}
>
Share Secret
</Button>
</div>
<div className="dark mb-4 mt-4 flex-grow text-sm text-bunker-300">
<div className="mb-2">Version History</div>
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
{secretVersion?.map(({ createdAt, secretValue, id }, i) => (
<div key={id} className="flex flex-col space-y-1">
<div className="flex items-center space-x-2">
<div>
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
<div className="mb-4flex-grow dark cursor-default text-sm text-bunker-300">
<div className="mb-2 pl-1">Version History</div>
<div className="thin-scrollbar flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4 dark:[color-scheme:dark]">
{secretVersion?.map(({ createdAt, secretValue, version, id }) => (
<div className="flex flex-row">
<div key={id} className="flex w-full flex-col space-y-1">
<div className="flex items-center">
<div className="w-10">
<div className="w-fit rounded-md border border-mineshaft-600 bg-mineshaft-700 px-1 text-sm text-mineshaft-300">
v{version}
</div>
</div>
<div>{format(new Date(createdAt), "Pp")}</div>
</div>
<div className="flex w-full cursor-default">
<div className="relative w-10">
<div className="absolute bottom-0 left-3 top-0 mt-0.5 border-l border-mineshaft-400/60" />
</div>
<div className="flex flex-row">
<div className="h-min w-fit rounded-sm bg-primary-500/10 px-1 text-primary-300/70">
Value:
</div>
<div className="group break-all pl-1 font-mono">
<div className="relative hidden cursor-pointer transition-all duration-200 group-[.show-value]:inline">
<button
type="button"
className="select-none"
onClick={(e) => {
navigator.clipboard.writeText(secretValue || "");
const target = e.currentTarget;
target.style.borderBottom = "1px dashed";
target.style.paddingBottom = "-1px";
// Create and insert popup
const popup = document.createElement("div");
popup.className =
"w-16 flex justify-center absolute top-6 left-0 text-xs text-primary-100 bg-mineshaft-800 px-1 py-0.5 rounded-md border border-primary-500/50";
popup.textContent = "Copied!";
target.parentElement?.appendChild(popup);
// Remove popup and border after delay
setTimeout(() => {
popup.remove();
target.style.borderBottom = "none";
}, 3000);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
navigator.clipboard.writeText(secretValue || "");
const target = e.currentTarget;
target.style.borderBottom = "1px dashed";
target.style.paddingBottom = "-1px";
// Create and insert popup
const popup = document.createElement("div");
popup.className =
"w-16 flex justify-center absolute top-6 left-0 text-xs text-primary-100 bg-mineshaft-800 px-1 py-0.5 rounded-md border border-primary-500/50";
popup.textContent = "Copied!";
target.parentElement?.appendChild(popup);
// Remove popup and border after delay
setTimeout(() => {
popup.remove();
target.style.borderBottom = "none";
}, 3000);
}
}}
>
{secretValue}
</button>
<button
type="button"
className="ml-1 cursor-pointer"
onClick={(e) => {
e.stopPropagation();
e.currentTarget
.closest(".group")
?.classList.remove("show-value");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.currentTarget
.closest(".group")
?.classList.remove("show-value");
}
}}
>
<FontAwesomeIcon icon={faEyeSlash} />
</button>
</div>
<span className="group-[.show-value]:hidden">
{secretValue?.replace(/./g, "*")}
<button
type="button"
className="ml-1 cursor-pointer"
onClick={(e) => {
e.currentTarget.closest(".group")?.classList.add("show-value");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.currentTarget
.closest(".group")
?.classList.add("show-value");
}
}}
>
<FontAwesomeIcon icon={faEye} />
</button>
</span>
</div>
</div>
</div>
<div>{format(new Date(createdAt), "Pp")}</div>
</div>
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
<div className="break-all font-mono">{secretValue}</div>
<div
className={`flex items-center justify-center ${version === secretVersion.length ? "hidden" : ""}`}
>
<Tooltip content="Restore Secret Value">
<IconButton
ariaLabel="Restore"
variant="outline_bg"
size="sm"
className="h-8 w-8 rounded-md"
onClick={() => setValue("value", secretValue)}
>
<FontAwesomeIcon icon={faArrowRotateRight} />
</IconButton>
</Tooltip>
</div>
</div>
))}
</div>
</div>
<div className="dark mb-4 flex-grow text-sm text-bunker-300">
<div className="mb-2">
<div className="mb-2 mt-4">
Access List
<Tooltip
content="Lists all users, machine identities, and groups that have been granted any permission level (read, create, edit, or delete) for this secret."
className="z-[100]"
>
<FontAwesomeIcon icon={faCircleQuestion} className="ml-1" size="sm" />
<FontAwesomeIcon icon={faCircleQuestion} className="ml-2" />
</Tooltip>
</div>
{isPending && (
@ -608,14 +770,22 @@ export const SecretDetailSidebar = ({
</Button>
)}
{!isPending && secretAccessList && (
<div className="flex max-h-72 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
<div className="mb-4 flex max-h-72 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 p-4 dark:[color-scheme:dark]">
{secretAccessList.users.length > 0 && (
<div className="pb-3">
<div className="mb-2 font-bold">Users</div>
<div className="flex flex-wrap gap-2">
{secretAccessList.users.map((user) => (
<div className="rounded-md bg-bunker-500 px-1">
<Tooltip content={user.allowedActions.join(", ")} className="z-[100]">
<div className="rounded-md bg-bunker-500">
<Tooltip
content={user.allowedActions
.map(
(action) =>
action.charAt(0).toUpperCase() + action.slice(1).toLowerCase()
)
.join(", ")}
className="z-[100]"
>
<Link
to={
`/${ProjectType.SecretManager}/$projectId/members/$membershipId` as const
@ -624,7 +794,7 @@ export const SecretDetailSidebar = ({
projectId: currentWorkspace.id,
membershipId: user.membershipId
}}
className="text-secondary/80 text-sm hover:text-primary"
className="text-secondary/80 rounded-md border border-mineshaft-600 bg-mineshaft-700 px-1 py-0.5 text-sm hover:text-primary"
>
{user.name}
</Link>
@ -639,9 +809,14 @@ export const SecretDetailSidebar = ({
<div className="mb-2 font-bold">Identities</div>
<div className="flex flex-wrap gap-2">
{secretAccessList.identities.map((identity) => (
<div className="rounded-md bg-bunker-500 px-1">
<div className="rounded-md bg-bunker-500">
<Tooltip
content={identity.allowedActions.join(", ")}
content={identity.allowedActions
.map(
(action) =>
action.charAt(0).toUpperCase() + action.slice(1).toLowerCase()
)
.join(", ")}
className="z-[100]"
>
<Link
@ -652,7 +827,7 @@ export const SecretDetailSidebar = ({
projectId: currentWorkspace.id,
identityId: identity.id
}}
className="text-secondary/80 text-sm hover:text-primary"
className="text-secondary/80 rounded-md border border-mineshaft-600 bg-mineshaft-700 px-1 py-0.5 text-sm hover:text-primary"
>
{identity.name}
</Link>
@ -667,9 +842,14 @@ export const SecretDetailSidebar = ({
<div className="mb-2 font-bold">Groups</div>
<div className="flex flex-wrap gap-2">
{secretAccessList.groups.map((group) => (
<div className="rounded-md bg-bunker-500 px-1">
<div className="rounded-md bg-bunker-500">
<Tooltip
content={group.allowedActions.join(", ")}
content={group.allowedActions
.map(
(action) =>
action.charAt(0).toUpperCase() + action.slice(1).toLowerCase()
)
.join(", ")}
className="z-[100]"
>
<Link
@ -677,7 +857,7 @@ export const SecretDetailSidebar = ({
params={{
groupId: group.id
}}
className="text-secondary/80 text-sm hover:text-primary"
className="text-secondary/80 rounded-md border border-mineshaft-600 bg-mineshaft-700 px-1 py-0.5 text-sm hover:text-primary"
>
{group.name}
</Link>
@ -691,7 +871,7 @@ export const SecretDetailSidebar = ({
)}
</div>
<div className="flex flex-col space-y-4">
<div className="mb-2 flex items-center space-x-4">
<div className="mb-4 flex items-center space-x-4">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
@ -705,6 +885,7 @@ export const SecretDetailSidebar = ({
<Button
isFullWidth
type="submit"
variant="outline_bg"
isDisabled={isSubmitting || !isDirty || !isAllowed}
isLoading={isSubmitting}
>
@ -722,9 +903,17 @@ export const SecretDetailSidebar = ({
})}
>
{(isAllowed) => (
<Button colorSchema="danger" isDisabled={!isAllowed} onClick={onDeleteSecret}>
Delete
</Button>
<IconButton
colorSchema="danger"
ariaLabel="Delete Secret"
className="border border-mineshaft-600 bg-mineshaft-700 hover:border-red-500/70 hover:bg-red-600/20"
isDisabled={!isAllowed}
onClick={onDeleteSecret}
>
<Tooltip content="Delete Secret">
<FontAwesomeIcon icon={faTrash} />
</Tooltip>
</IconButton>
)}
</ProjectPermissionCan>
</div>

View File

@ -37,7 +37,7 @@ const PageContent = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["editSync"] as const);
const { data: secretSync, isPending } = useGetSecretSync(destination, syncId, {
refetchInterval: 4000
refetchInterval: 30000
});
if (isPending) {
@ -66,7 +66,7 @@ const PageContent = () => {
const handleEditSource = () => handlePopUpOpen("editSync", SecretSyncEditFields.Source);
// const handleEditOptions = () => handlePopUpOpen("editSync", SecretSyncEditFields.Options);
const handleEditOptions = () => handlePopUpOpen("editSync", SecretSyncEditFields.Options);
const handleEditDestination = () => handlePopUpOpen("editSync", SecretSyncEditFields.Destination);
@ -108,10 +108,7 @@ const PageContent = () => {
<div className="mr-4 flex w-72 flex-col gap-4">
<SecretSyncDetailsSection secretSync={secretSync} onEditDetails={handleEditDetails} />
<SecretSyncSourceSection secretSync={secretSync} onEditSource={handleEditSource} />
<SecretSyncOptionsSection
secretSync={secretSync}
// onEditOptions={handleEditOptions}
/>
<SecretSyncOptionsSection secretSync={secretSync} onEditOptions={handleEditOptions} />
</div>
<div className="flex flex-1 flex-col gap-4">
<SecretSyncDestinationSection

View File

@ -1,57 +0,0 @@
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP } from "@app/helpers/secretSyncs";
import { TSecretSync } from "@app/hooks/api/secretSyncs";
type Props = {
secretSync: TSecretSync;
// onEditOptions: VoidFunction;
};
export const SecretSyncOptionsSection = ({
secretSync
// onEditOptions
}: Props) => {
const {
destination,
syncOptions: {
// appendSuffix,
// prependPrefix,
initialSyncBehavior
}
} = secretSync;
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Sync Options</h3>
{/* <ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit sync options"
onClick={onEditOptions}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan> */}
</div>
<div>
<div className="space-y-3">
<SecretSyncLabel label="Initial Sync Behavior">
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destination).name}
</SecretSyncLabel>
{/* <SecretSyncLabel label="Prefix">{prependPrefix}</SecretSyncLabel>
<SecretSyncLabel label="Suffix">{appendSuffix}</SecretSyncLabel> */}
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,60 @@
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync";
type Props = {
secretSync: TAwsParameterStoreSync;
};
export const AwsParameterStoreSyncOptionsSection = ({ secretSync }: Props) => {
const {
syncOptions: { keyId, tags, syncSecretMetadataAsTags }
} = secretSync;
return (
<>
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
{tags && tags.length > 0 && (
<SecretSyncLabel label="Resource Tags">
<Tooltip
side="right"
className="max-w-xl p-1"
content={
<Table>
<THead>
<Th className="whitespace-nowrap p-2">Key</Th>
<Th className="p-2">Value</Th>
</THead>
<TBody>
{tags.map((tag) => (
<Tr key={tag.key}>
<Td className="p-2">{tag.key}</Td>
<Td className="p-2">{tag.value}</Td>
</Tr>
))}
</TBody>
</Table>
}
>
<div className="w-min">
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faEye} />
<span>
{tags.length} Tag{tags.length > 1 ? "s" : ""}
</span>
</Badge>
</div>
</Tooltip>
</SecretSyncLabel>
)}
{syncSecretMetadataAsTags && (
<SecretSyncLabel label="Sync Secret Metadata as Resource Tags">
<Badge variant="success">Enabled</Badge>
</SecretSyncLabel>
)}
</>
);
};

View File

@ -0,0 +1,60 @@
import { faEye } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2";
import { TAwsSecretsManagerSync } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync";
type Props = {
secretSync: TAwsSecretsManagerSync;
};
export const AwsSecretsManagerSyncOptionsSection = ({ secretSync }: Props) => {
const {
syncOptions: { keyId, tags, syncSecretMetadataAsTags }
} = secretSync;
return (
<>
{keyId && <SecretSyncLabel label="KMS Key">{keyId}</SecretSyncLabel>}
{tags && tags.length > 0 && (
<SecretSyncLabel label="Tags">
<Tooltip
side="right"
className="max-w-xl p-1"
content={
<Table>
<THead>
<Th className="whitespace-nowrap p-2">Key</Th>
<Th className="p-2">Value</Th>
</THead>
<TBody>
{tags.map((tag) => (
<Tr key={tag.key}>
<Td className="p-2">{tag.key}</Td>
<Td className="p-2">{tag.value}</Td>
</Tr>
))}
</TBody>
</Table>
}
>
<div className="w-min">
<Badge className="flex h-5 w-min items-center gap-1.5 whitespace-nowrap bg-mineshaft-400/50 text-bunker-300">
<FontAwesomeIcon icon={faEye} />
<span>
{tags.length} Tag{tags.length > 1 ? "s" : ""}
</span>
</Badge>
</div>
</Tooltip>
</SecretSyncLabel>
)}
{syncSecretMetadataAsTags && (
<SecretSyncLabel label="Sync Secret Metadata as Tags">
<Badge variant="success">Enabled</Badge>
</SecretSyncLabel>
)}
</>
);
};

View File

@ -0,0 +1,92 @@
import { ReactNode } from "react";
import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions";
import { SecretSyncLabel } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types";
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP } from "@app/helpers/secretSyncs";
import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs";
import { AwsParameterStoreSyncOptionsSection } from "./AwsParameterStoreSyncOptionsSection";
import { AwsSecretsManagerSyncOptionsSection } from "./AwsSecretsManagerSyncOptionsSection";
type Props = {
secretSync: TSecretSync;
onEditOptions: VoidFunction;
};
export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) => {
const {
destination,
syncOptions: {
// appendSuffix,
// prependPrefix,
initialSyncBehavior
}
} = secretSync;
let AdditionalSyncOptionsComponent: ReactNode;
switch (destination) {
case SecretSync.AWSParameterStore:
AdditionalSyncOptionsComponent = (
<AwsParameterStoreSyncOptionsSection secretSync={secretSync} />
);
break;
case SecretSync.AWSSecretsManager:
AdditionalSyncOptionsComponent = (
<AwsSecretsManagerSyncOptionsSection secretSync={secretSync} />
);
break;
case SecretSync.GitHub:
case SecretSync.GCPSecretManager:
case SecretSync.AzureKeyVault:
case SecretSync.AzureAppConfiguration:
case SecretSync.Databricks:
AdditionalSyncOptionsComponent = null;
break;
default:
throw new Error(`Unhandled Destination Review Fields: ${destination}`);
}
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-semibold text-mineshaft-100">Sync Options</h3>
{AdditionalSyncOptionsComponent && (
<ProjectPermissionCan
I={ProjectPermissionSecretSyncActions.Edit}
a={ProjectPermissionSub.SecretSyncs}
>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit sync options"
onClick={onEditOptions}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</div>
<div>
<div className="space-y-3">
<SecretSyncLabel label="Initial Sync Behavior">
{SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destination).name}
</SecretSyncLabel>
{/* <SecretSyncLabel label="Prefix">{prependPrefix}</SecretSyncLabel>
<SecretSyncLabel label="Suffix">{appendSuffix}</SecretSyncLabel> */}
{AdditionalSyncOptionsComponent}
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export * from "./SecretSyncOptionsSection";