mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #1873 from Infisical/feat/add-personal-overrides-and-secret-reference-to-download-envs
feat: added personal overrides and support for secret ref during download
This commit is contained in:
175
frontend/src/helpers/secret.ts
Normal file
175
frontend/src/helpers/secret.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import path from "path";
|
||||||
|
|
||||||
|
import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto";
|
||||||
|
import { fetchProjectEncryptedSecrets } from "@app/hooks/api/secrets/queries";
|
||||||
|
|
||||||
|
const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g;
|
||||||
|
export const interpolateSecrets = ({
|
||||||
|
projectId,
|
||||||
|
secretEncKey
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
secretEncKey: string;
|
||||||
|
}) => {
|
||||||
|
const fetchSecretsCrossEnv = () => {
|
||||||
|
const fetchCache: Record<string, Record<string, string>> = {};
|
||||||
|
|
||||||
|
return async (secRefEnv: string, secRefPath: string[], secRefKey: string) => {
|
||||||
|
const secRefPathUrl = path.join("/", ...secRefPath);
|
||||||
|
const uniqKey = `${secRefEnv}-${secRefPathUrl}`;
|
||||||
|
|
||||||
|
if (fetchCache?.[uniqKey]) {
|
||||||
|
return fetchCache[uniqKey][secRefKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// get secrets by projectId, env, path
|
||||||
|
const encryptedSecrets = await fetchProjectEncryptedSecrets({
|
||||||
|
workspaceId: projectId,
|
||||||
|
environment: secRefEnv,
|
||||||
|
secretPath: secRefPathUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedSec = encryptedSecrets.reduce<Record<string, string>>((prev, secret) => {
|
||||||
|
const secretKey = decryptSymmetric({
|
||||||
|
ciphertext: secret.secretKeyCiphertext,
|
||||||
|
iv: secret.secretKeyIV,
|
||||||
|
tag: secret.secretKeyTag,
|
||||||
|
key: secretEncKey
|
||||||
|
});
|
||||||
|
const secretValue = decryptSymmetric({
|
||||||
|
ciphertext: secret.secretValueCiphertext,
|
||||||
|
iv: secret.secretValueIV,
|
||||||
|
tag: secret.secretValueTag,
|
||||||
|
key: secretEncKey
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
prev[secretKey] = secretValue;
|
||||||
|
return prev;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
fetchCache[uniqKey] = decryptedSec;
|
||||||
|
|
||||||
|
return fetchCache[uniqKey][secRefKey];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const recursivelyExpandSecret = async (
|
||||||
|
expandedSec: Record<string, string>,
|
||||||
|
interpolatedSec: Record<string, string>,
|
||||||
|
fetchCrossEnv: (env: string, secPath: string[], secKey: string) => Promise<string>,
|
||||||
|
recursionChainBreaker: Record<string, boolean>,
|
||||||
|
key: string
|
||||||
|
) => {
|
||||||
|
if (expandedSec?.[key] !== undefined) {
|
||||||
|
return expandedSec[key];
|
||||||
|
}
|
||||||
|
if (recursionChainBreaker?.[key]) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line
|
||||||
|
recursionChainBreaker[key] = true;
|
||||||
|
|
||||||
|
let interpolatedValue = interpolatedSec[key];
|
||||||
|
if (!interpolatedValue) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Couldn't find referenced value - ${key}`);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const refs = interpolatedValue.match(INTERPOLATION_SYNTAX_REG);
|
||||||
|
if (refs) {
|
||||||
|
await Promise.all(
|
||||||
|
refs.map(async (interpolationSyntax) => {
|
||||||
|
const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1);
|
||||||
|
const entities = interpolationKey.trim().split(".");
|
||||||
|
|
||||||
|
if (entities.length === 1) {
|
||||||
|
const val = await recursivelyExpandSecret(
|
||||||
|
expandedSec,
|
||||||
|
interpolatedSec,
|
||||||
|
fetchCrossEnv,
|
||||||
|
recursionChainBreaker,
|
||||||
|
interpolationKey
|
||||||
|
);
|
||||||
|
if (val) {
|
||||||
|
interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entities.length > 1) {
|
||||||
|
const secRefEnv = entities[0];
|
||||||
|
const secRefPath = entities.slice(1, entities.length - 1);
|
||||||
|
const secRefKey = entities[entities.length - 1];
|
||||||
|
|
||||||
|
const val = await fetchCrossEnv(secRefEnv, secRefPath, secRefKey);
|
||||||
|
if (val) {
|
||||||
|
interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
expandedSec[key] = interpolatedValue;
|
||||||
|
return interpolatedValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// used to convert multi line ones to quotes ones with \n
|
||||||
|
const formatMultiValueEnv = (val?: string) => {
|
||||||
|
if (!val) return "";
|
||||||
|
if (!val.match("\n")) return val;
|
||||||
|
return `"${val.replace(/\n/g, "\\n")}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandSecrets = async (
|
||||||
|
secrets: Record<string, { value: string; comment?: string; skipMultilineEncoding?: boolean }>
|
||||||
|
) => {
|
||||||
|
const expandedSec: Record<string, string> = {};
|
||||||
|
const interpolatedSec: Record<string, string> = {};
|
||||||
|
|
||||||
|
const crossSecEnvFetch = fetchSecretsCrossEnv();
|
||||||
|
|
||||||
|
Object.keys(secrets).forEach((key) => {
|
||||||
|
if (secrets[key].value.match(INTERPOLATION_SYNTAX_REG)) {
|
||||||
|
interpolatedSec[key] = secrets[key].value;
|
||||||
|
} else {
|
||||||
|
expandedSec[key] = secrets[key].value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Object.keys(secrets).map(async (key) => {
|
||||||
|
if (expandedSec?.[key]) {
|
||||||
|
// should not do multi line encoding if user has set it to skip
|
||||||
|
// eslint-disable-next-line
|
||||||
|
secrets[key].value = secrets[key].skipMultilineEncoding
|
||||||
|
? expandedSec[key]
|
||||||
|
: formatMultiValueEnv(expandedSec[key]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is to avoid recursion loop. So the graph should be direct graph rather than cyclic
|
||||||
|
// so for any recursion building if there is an entity two times same key meaning it will be looped
|
||||||
|
const recursionChainBreaker: Record<string, boolean> = {};
|
||||||
|
const expandedVal = await recursivelyExpandSecret(
|
||||||
|
expandedSec,
|
||||||
|
interpolatedSec,
|
||||||
|
crossSecEnvFetch,
|
||||||
|
recursionChainBreaker,
|
||||||
|
key
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
secrets[key].value = secrets[key].skipMultilineEncoding
|
||||||
|
? expandedVal
|
||||||
|
: formatMultiValueEnv(expandedVal);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return secrets;
|
||||||
|
};
|
||||||
|
return expandSecrets;
|
||||||
|
};
|
||||||
@@ -98,7 +98,7 @@ export const decryptSecrets = (
|
|||||||
return secrets;
|
return secrets;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchProjectEncryptedSecrets = async ({
|
export const fetchProjectEncryptedSecrets = async ({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
environment,
|
environment,
|
||||||
secretPath
|
secretPath
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
|
import { decryptAssymmetric } from "@app/components/utilities/cryptography/crypto";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
DeleteActionModal,
|
DeleteActionModal,
|
||||||
@@ -43,8 +44,9 @@ import {
|
|||||||
UpgradePlanModal
|
UpgradePlanModal
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
||||||
|
import { interpolateSecrets } from "@app/helpers/secret";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api";
|
||||||
import { DecryptedSecret, TImportedSecrets, WsTag } from "@app/hooks/api/types";
|
import { DecryptedSecret, TImportedSecrets, WsTag } from "@app/hooks/api/types";
|
||||||
import { debounce } from "@app/lib/fn/debounce";
|
import { debounce } from "@app/lib/fn/debounce";
|
||||||
|
|
||||||
@@ -112,6 +114,7 @@ export const ActionBar = ({
|
|||||||
|
|
||||||
const { mutateAsync: createFolder } = useCreateFolder();
|
const { mutateAsync: createFolder } = useCreateFolder();
|
||||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||||
|
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
||||||
|
|
||||||
const selectedSecrets = useSelectedSecrets();
|
const selectedSecrets = useSelectedSecrets();
|
||||||
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
||||||
@@ -144,30 +147,59 @@ export const ActionBar = ({
|
|||||||
const handleSecretDownload = async () => {
|
const handleSecretDownload = async () => {
|
||||||
const secPriority: Record<string, boolean> = {};
|
const secPriority: Record<string, boolean> = {};
|
||||||
const downloadedSecrets: Array<{ key: string; value: string; comment?: string }> = [];
|
const downloadedSecrets: Array<{ key: string; value: string; comment?: string }> = [];
|
||||||
|
|
||||||
|
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||||
|
const workspaceKey = decryptAssymmetric({
|
||||||
|
ciphertext: decryptFileKey!.encryptedKey,
|
||||||
|
nonce: decryptFileKey!.nonce,
|
||||||
|
publicKey: decryptFileKey!.sender.publicKey,
|
||||||
|
privateKey: PRIVATE_KEY
|
||||||
|
});
|
||||||
|
|
||||||
|
const expandSecrets = interpolateSecrets({
|
||||||
|
projectId: workspaceId,
|
||||||
|
secretEncKey: workspaceKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretRecord: Record<
|
||||||
|
string,
|
||||||
|
{ value: string; comment?: string; skipMultilineEncoding?: boolean }
|
||||||
|
> = {};
|
||||||
|
|
||||||
// load up secrets in dashboard
|
// load up secrets in dashboard
|
||||||
secrets?.forEach(({ key, value, comment }) => {
|
secrets?.forEach(({ key, value, valueOverride, comment }) => {
|
||||||
secPriority[key] = true;
|
secPriority[key] = true;
|
||||||
downloadedSecrets.push({ key, value, comment });
|
downloadedSecrets.push({ key, value: valueOverride || value, comment });
|
||||||
});
|
});
|
||||||
// now load imported secrets with secPriority
|
// now load imported secrets with secPriority
|
||||||
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
|
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
|
||||||
importedSecrets[i].secrets.forEach(({ key, value, comment }) => {
|
importedSecrets[i].secrets.forEach(({ key, value, valueOverride, comment }) => {
|
||||||
if (secPriority?.[key]) return;
|
if (secPriority?.[key]) return;
|
||||||
downloadedSecrets.unshift({ key, value, comment });
|
downloadedSecrets.unshift({ key, value: valueOverride || value, comment });
|
||||||
secPriority[key] = true;
|
secPriority[key] = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadedSecrets.forEach((secret) => {
|
||||||
|
secretRecord[secret.key] = {
|
||||||
|
value: secret.value,
|
||||||
|
comment: secret.comment
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await expandSecrets(secretRecord);
|
||||||
|
|
||||||
const file = downloadedSecrets
|
const file = downloadedSecrets
|
||||||
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase()))
|
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase()))
|
||||||
.reduce(
|
.reduce(
|
||||||
(prev, { key, value, comment }, index) =>
|
(prev, { key, comment }, index) =>
|
||||||
prev +
|
prev +
|
||||||
(comment
|
(comment
|
||||||
? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n`
|
? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${secretRecord[key].value}\n`
|
||||||
: `${key}=${value}\n`),
|
: `${key}=${secretRecord[key].value}\n`),
|
||||||
""
|
""
|
||||||
);
|
);
|
||||||
|
|
||||||
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
|
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
|
||||||
FileSaver.saveAs(blob, `${environment}.env`);
|
FileSaver.saveAs(blob, `${environment}.env`);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user