feat(secret-syncs): azure app config & key vault support

This commit is contained in:
Daniel Hougaard
2025-02-08 04:27:17 +04:00
parent 4e41e84491
commit 5cd6a66989
70 changed files with 2065 additions and 204 deletions
@@ -0,0 +1,10 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
export const AZURE_KEY_VAULT_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Azure Key Vault",
destination: SecretSync.AzureKeyVault,
connection: AppConnection.Azure,
canImportSecrets: false
};
@@ -0,0 +1,246 @@
/* eslint-disable no-await-in-loop */
import { AxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import { GetAzureKeyVaultSecret, TAzureKeyVaultSyncWithCredentials } from "./azure-key-vault-sync-types";
type TAzureKeyVaultSecretSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
export const azureKeyVaultSecretSyncFactory = ({
kmsService,
appConnectionDAL
}: TAzureKeyVaultSecretSyncFactoryDeps) => {
const $getAzureKeyVaultSecrets = async (accessToken: string, vaultBaseUrl: string) => {
const paginateAzureKeyVaultSecrets = async () => {
let result: GetAzureKeyVaultSecret[] = [];
let currentUrl = `${vaultBaseUrl}/secrets?api-version=7.3`;
while (currentUrl) {
const res = await request.get<{ value: GetAzureKeyVaultSecret; nextLink: string }>(currentUrl, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
result = result.concat(res.data.value);
currentUrl = res.data.nextLink;
}
return result;
};
const getAzureKeyVaultSecrets = await paginateAzureKeyVaultSecrets();
const enabledAzureKeyVaultSecrets = getAzureKeyVaultSecrets.filter((secret) => secret.attributes.enabled);
// disabled keys to skip sending updates to
const disabledAzureKeyVaultSecretKeys = getAzureKeyVaultSecrets
.filter(({ attributes }) => !attributes.enabled)
.map((getAzureKeyVaultSecret) => {
return getAzureKeyVaultSecret.id.substring(getAzureKeyVaultSecret.id.lastIndexOf("/") + 1);
});
let lastSlashIndex: number;
const res = (
await Promise.all(
enabledAzureKeyVaultSecrets.map(async (getAzureKeyVaultSecret) => {
if (!lastSlashIndex) {
lastSlashIndex = getAzureKeyVaultSecret.id.lastIndexOf("/");
}
const azureKeyVaultSecret = await request.get<GetAzureKeyVaultSecret>(
`${getAzureKeyVaultSecret.id}?api-version=7.3`,
{
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
return {
...azureKeyVaultSecret.data,
key: getAzureKeyVaultSecret.id.substring(lastSlashIndex + 1)
};
})
)
).reduce(
(obj, secret) => ({
...obj,
[secret.key]: secret
}),
{} as Record<string, GetAzureKeyVaultSecret>
);
return {
vaultSecrets: res,
disabledAzureKeyVaultSecretKeys
};
};
const syncSecrets = async (secretSync: TAzureKeyVaultSyncWithCredentials, secretMap: TSecretMap) => {
const { accessToken } = await getAzureConnectionAccessToken(secretSync.connection.id, appConnectionDAL, kmsService);
const { vaultSecrets, disabledAzureKeyVaultSecretKeys } = await $getAzureKeyVaultSecrets(
accessToken,
secretSync.destinationConfig.vaultBaseUrl
);
const setSecrets: {
key: string;
value: string;
}[] = [];
const deleteSecrets: string[] = [];
Object.keys(secretMap).forEach((infisicalKey) => {
const hyphenatedKey = infisicalKey.replace(/_/g, "-");
if (!(hyphenatedKey in vaultSecrets)) {
// case: secret has been created
setSecrets.push({
key: hyphenatedKey,
value: secretMap[infisicalKey].value
});
} else if (secretMap[infisicalKey].value !== vaultSecrets[hyphenatedKey].value) {
// case: secret has been updated
setSecrets.push({
key: hyphenatedKey,
value: secretMap[infisicalKey].value
});
}
});
Object.keys(vaultSecrets).forEach((key) => {
const underscoredKey = key.replace(/-/g, "_");
if (!(underscoredKey in secretMap)) {
deleteSecrets.push(key);
}
});
const setSecretAzureKeyVault = async ({ key, value }: { key: string; value: string }) => {
let isSecretSet = false;
let maxTries = 6;
if (disabledAzureKeyVaultSecretKeys.includes(key)) return;
while (!isSecretSet && maxTries > 0) {
// try to set secret
try {
await request.put(
`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${key}?api-version=7.3`,
{
value
},
{
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
isSecretSet = true;
} catch (err) {
if (err instanceof AxiosError) {
// eslint-disable-next-line
if (err.response?.data?.error?.innererror?.code === "ObjectIsDeletedButRecoverable") {
await request.post(
`${secretSync.destinationConfig.vaultBaseUrl}/deletedsecrets/${key}/recover?api-version=7.3`,
{},
{
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
await new Promise((resolve) => {
setTimeout(resolve, 10_000);
});
} else {
await new Promise((resolve) => {
setTimeout(resolve, 10_000);
});
maxTries -= 1;
}
}
}
}
};
for await (const setSecret of setSecrets) {
const { key, value } = setSecret;
await setSecretAzureKeyVault({
key,
value
});
}
for await (const deleteSecretKey of deleteSecrets.filter(
(secret) => !setSecrets.find((setSecret) => setSecret.key === secret)
)) {
await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${deleteSecretKey}?api-version=7.3`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
}
};
const removeSecrets = async (secretSync: TAzureKeyVaultSyncWithCredentials, secretMap: TSecretMap) => {
const { accessToken } = await getAzureConnectionAccessToken(secretSync.connection.id, appConnectionDAL, kmsService);
const { vaultSecrets, disabledAzureKeyVaultSecretKeys } = await $getAzureKeyVaultSecrets(
accessToken,
secretSync.destinationConfig.vaultBaseUrl
);
for await (const [key] of Object.entries(vaultSecrets)) {
const underscoredKey = key.replace(/-/g, "_");
if (underscoredKey in secretMap) {
if (!disabledAzureKeyVaultSecretKeys.includes(underscoredKey)) {
await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${key}?api-version=7.3`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
});
}
}
}
};
const getSecrets = async (secretSync: TAzureKeyVaultSyncWithCredentials) => {
const { accessToken } = await getAzureConnectionAccessToken(secretSync.connection.id, appConnectionDAL, kmsService);
const { vaultSecrets, disabledAzureKeyVaultSecretKeys } = await $getAzureKeyVaultSecrets(
accessToken,
secretSync.destinationConfig.vaultBaseUrl
);
const secretMap: TSecretMap = {};
Object.keys(vaultSecrets).forEach((key) => {
if (!disabledAzureKeyVaultSecretKeys.includes(key)) {
const underscoredKey = key.replace(/-/g, "_");
secretMap[underscoredKey] = {
value: vaultSecrets[key].value
};
}
});
return secretMap;
};
return {
syncSecrets,
removeSecrets,
getSecrets
};
};
@@ -0,0 +1,45 @@
import { z } from "zod";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const AzureKeyVaultSyncDestinationConfigSchema = z.object({
vaultBaseUrl: z.string().min(1, "Vault base URL required")
});
const AzureKeyVaultSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
export const AzureKeyVaultSyncSchema = BaseSecretSyncSchema(
SecretSync.AzureKeyVault,
AzureKeyVaultSyncOptionsConfig
).extend({
destination: z.literal(SecretSync.AzureKeyVault),
destinationConfig: AzureKeyVaultSyncDestinationConfigSchema
});
export const CreateAzureKeyVaultSyncSchema = GenericCreateSecretSyncFieldsSchema(
SecretSync.AzureKeyVault,
AzureKeyVaultSyncOptionsConfig
).extend({
destinationConfig: AzureKeyVaultSyncDestinationConfigSchema
});
export const UpdateAzureKeyVaultSyncSchema = GenericUpdateSecretSyncFieldsSchema(
SecretSync.AzureKeyVault,
AzureKeyVaultSyncOptionsConfig
).extend({
destinationConfig: AzureKeyVaultSyncDestinationConfigSchema.optional()
});
export const AzureKeyVaultSyncListItemSchema = z.object({
name: z.literal("Azure Key Vault"),
connection: z.literal(AppConnection.Azure),
destination: z.literal(SecretSync.AzureKeyVault),
canImportSecrets: z.literal(false)
});
@@ -0,0 +1,35 @@
import { z } from "zod";
import { TAzureConnection } from "@app/services/app-connection/azure";
import {
AzureKeyVaultSyncListItemSchema,
AzureKeyVaultSyncSchema,
CreateAzureKeyVaultSyncSchema
} from "./azure-key-vault-sync-schemas";
export type TAzureKeyVaultSync = z.infer<typeof AzureKeyVaultSyncSchema>;
export type TAzureKeyVaultSyncInput = z.infer<typeof CreateAzureKeyVaultSyncSchema>;
export type TAzureKeyVaultSyncListItem = z.infer<typeof AzureKeyVaultSyncListItemSchema>;
export type TAzureKeyVaultSyncWithCredentials = TAzureKeyVaultSync & {
connection: TAzureConnection;
};
export interface GetAzureKeyVaultSecret {
id: string; // secret URI
value: string;
attributes: {
enabled: boolean;
created: number;
updated: number;
recoveryLevel: string;
recoverableDays: number;
};
}
export interface AzureKeyVaultSecret extends GetAzureKeyVaultSecret {
key: string;
}
@@ -0,0 +1,4 @@
export * from "./azure-key-vault-sync-constants";
export * from "./azure-key-vault-sync-fns";
export * from "./azure-key-vault-sync-schemas";
export * from "./azure-key-vault-sync-types";