mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 01:05:40 +00:00
feat: changed backend-pg to backend
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { Knex } from "knex";
|
||||
import { validate as uuidValidate } from "uuid";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { SecretsSchema, SecretType, TableName, TSecrets, TSecretsUpdate } from "@app/db/schemas";
|
||||
import { BadRequestError, DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
|
||||
export type TSecretDALFactory = ReturnType<typeof secretDALFactory>;
|
||||
|
||||
export const secretDALFactory = (db: TDbClient) => {
|
||||
const secretOrm = ormify(db, TableName.Secret);
|
||||
|
||||
const update = async (
|
||||
filter: Partial<TSecrets>,
|
||||
data: Omit<TSecretsUpdate, "version">,
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const sec = await (tx || db)(TableName.Secret)
|
||||
.where(filter)
|
||||
.update(data)
|
||||
.increment("version", 1)
|
||||
.returning("*");
|
||||
return sec;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "update secret" });
|
||||
}
|
||||
};
|
||||
|
||||
// the idea is to use postgres specific function
|
||||
// insert with id this will cause a conflict then merge the data
|
||||
const bulkUpdate = async (
|
||||
data: Array<{ filter: Partial<TSecrets>; data: TSecretsUpdate }>,
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const secs = await Promise.all(
|
||||
data.map(async ({ filter, data: updateData }) => {
|
||||
const [doc] = await (tx || db)(TableName.Secret)
|
||||
.where(filter)
|
||||
.update(updateData)
|
||||
.increment("version", 1)
|
||||
.returning("*");
|
||||
if (!doc) throw new BadRequestError({ message: "Failed to update document" });
|
||||
return doc;
|
||||
})
|
||||
);
|
||||
return secs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "bulk update secret" });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMany = async (
|
||||
data: Array<{ blindIndex: string; type: SecretType }>,
|
||||
folderId: string,
|
||||
userId: string,
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const deletedSecrets = await (tx || db)(TableName.Secret)
|
||||
.where({ folderId })
|
||||
.where((bd) => {
|
||||
data.forEach((el) => {
|
||||
bd.orWhere({
|
||||
secretBlindIndex: el.blindIndex,
|
||||
type: el.type,
|
||||
...(el.type === SecretType.Personal ? { userId } : {})
|
||||
});
|
||||
});
|
||||
})
|
||||
.delete()
|
||||
.returning("*");
|
||||
return deletedSecrets;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "delete many secret" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByFolderId = async (folderId: string, userId?: string, tx?: Knex) => {
|
||||
try {
|
||||
// check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo)
|
||||
if (userId && !uuidValidate(userId)) {
|
||||
// eslint-disable-next-line
|
||||
userId = undefined;
|
||||
}
|
||||
|
||||
const secs = await (tx || db)(TableName.Secret)
|
||||
.where({ folderId })
|
||||
.where((bd) => {
|
||||
bd.whereNull("userId").orWhere({ userId: userId || null });
|
||||
})
|
||||
.leftJoin(
|
||||
TableName.JnSecretTag,
|
||||
`${TableName.Secret}.id`,
|
||||
`${TableName.JnSecretTag}.${TableName.Secret}Id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.SecretTag,
|
||||
`${TableName.JnSecretTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.Secret))
|
||||
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
|
||||
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
|
||||
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
|
||||
.select(db.ref("name").withSchema(TableName.SecretTag).as("tagName"));
|
||||
const data = sqlNestRelationships({
|
||||
data: secs,
|
||||
key: "id",
|
||||
parentMapper: (el) => SecretsSchema.parse(el),
|
||||
childrenMapper: [
|
||||
{
|
||||
key: "tagId",
|
||||
label: "tags" as const,
|
||||
mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({
|
||||
id,
|
||||
color,
|
||||
slug,
|
||||
name
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "get all secret" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByBlindIndexes = async (
|
||||
folderId: string,
|
||||
blindIndexes: Array<{ blindIndex: string; type: SecretType }>,
|
||||
userId?: string,
|
||||
tx?: Knex
|
||||
) => {
|
||||
if (!blindIndexes.length) return [];
|
||||
try {
|
||||
const secrets = await (tx || db)(TableName.Secret)
|
||||
.where({ folderId })
|
||||
.where((bd) => {
|
||||
blindIndexes.forEach((el) => {
|
||||
if (el.type === SecretType.Personal && !userId) {
|
||||
throw new BadRequestError({ message: "Missing personal user id" });
|
||||
}
|
||||
bd.orWhere({
|
||||
secretBlindIndex: el.blindIndex,
|
||||
type: el.type,
|
||||
userId: el.type === SecretType.Personal ? userId : null
|
||||
});
|
||||
});
|
||||
});
|
||||
return secrets;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "find by blind indexes" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretOrm, update, bulkUpdate, deleteMany, findByFolderId, findByBlindIndexes };
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import path from "path";
|
||||
|
||||
import { SecretKeyEncoding, TSecretBlindIndexes, TSecrets } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { buildSecretBlindIndexFromName, decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretDALFactory } from "./secret-dal";
|
||||
|
||||
export const generateSecretBlindIndexBySalt = async (
|
||||
secretName: string,
|
||||
secretBlindIndexDoc: TSecretBlindIndexes
|
||||
) => {
|
||||
const appCfg = getConfig();
|
||||
const secretBlindIndex = await buildSecretBlindIndexFromName({
|
||||
secretName,
|
||||
keyEncoding: secretBlindIndexDoc.keyEncoding as SecretKeyEncoding,
|
||||
rootEncryptionKey: appCfg.ROOT_ENCRYPTION_KEY,
|
||||
encryptionKey: appCfg.ENCRYPTION_KEY,
|
||||
tag: secretBlindIndexDoc.saltTag,
|
||||
ciphertext: secretBlindIndexDoc.encryptedSaltCipherText,
|
||||
iv: secretBlindIndexDoc.saltIV
|
||||
});
|
||||
return secretBlindIndex;
|
||||
};
|
||||
|
||||
type TInterpolateSecretArg = {
|
||||
projectId: string;
|
||||
secretEncKey: string;
|
||||
secretDAL: Pick<TSecretDALFactory, "findByFolderId">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
||||
};
|
||||
|
||||
export const interpolateSecrets = ({
|
||||
projectId,
|
||||
secretEncKey,
|
||||
secretDAL,
|
||||
folderDAL
|
||||
}: TInterpolateSecretArg) => {
|
||||
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];
|
||||
}
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, secRefEnv, secRefPathUrl);
|
||||
if (!folder) return "";
|
||||
const secrets = await secretDAL.findByFolderId(folder.id);
|
||||
|
||||
const decryptedSec = secrets.reduce<Record<string, string>>((prev, secret) => {
|
||||
const secretKey = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key: secretEncKey
|
||||
});
|
||||
const secretValue = decryptSymmetric128BitHexKeyUTF8({
|
||||
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 INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g;
|
||||
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) {
|
||||
for (const interpolationSyntax of refs) {
|
||||
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);
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
for (const key of Object.keys(secrets)) {
|
||||
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]);
|
||||
// eslint-disable-next-line
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
export const decryptSecretRaw = (
|
||||
secret: TSecrets & { workspace: string; environment: string },
|
||||
key: string
|
||||
) => {
|
||||
const secretKey = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
let secretComment = "";
|
||||
|
||||
if (secret.secretCommentCiphertext && secret.secretCommentIV && secret.secretCommentTag) {
|
||||
secretComment = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretCommentCiphertext,
|
||||
iv: secret.secretCommentIV,
|
||||
tag: secret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
secretKey,
|
||||
workspace: secret.workspace,
|
||||
environment: secret.environment,
|
||||
secretValue,
|
||||
secretComment,
|
||||
version: secret.version,
|
||||
type: secret.type,
|
||||
_id: secret.id,
|
||||
id: secret.id,
|
||||
user: secret.userId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,405 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { isSamePath } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TIntegrationDALFactory } from "../integration/integration-dal";
|
||||
import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-auth-service";
|
||||
import { syncIntegrationSecrets } from "../integration-auth/integration-sync-secret";
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
||||
import { fnSecretsFromImports } from "../secret-import/secret-import-fns";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TWebhookDALFactory } from "../webhook/webhook-dal";
|
||||
import { fnTriggerWebhook } from "../webhook/webhook-fns";
|
||||
import { TSecretDALFactory } from "./secret-dal";
|
||||
import { interpolateSecrets } from "./secret-fns";
|
||||
import {
|
||||
TCreateSecretReminderDTO,
|
||||
THandleReminderDTO,
|
||||
TRemoveSecretReminderDTO
|
||||
} from "./secret-types";
|
||||
|
||||
export type TSecretQueueFactory = ReturnType<typeof secretQueueFactory>;
|
||||
|
||||
type TSecretQueueFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
integrationDAL: Pick<TIntegrationDALFactory, "findByProjectIdV2">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
integrationAuthService: Pick<TIntegrationAuthServiceFactory, "getIntegrationAccessToken">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findByManySecretPath">;
|
||||
secretDAL: Pick<TSecretDALFactory, "findByFolderId" | "find">;
|
||||
secretImportDAL: Pick<TSecretImportDALFactory, "find">;
|
||||
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
|
||||
smtpService: TSmtpService;
|
||||
orgDAL: Pick<TOrgDALFactory, "findOrgByProjectId">;
|
||||
};
|
||||
|
||||
export type TGetSecrets = {
|
||||
secretPath: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
export const secretQueueFactory = ({
|
||||
queueService,
|
||||
integrationDAL,
|
||||
projectBotService,
|
||||
integrationAuthService,
|
||||
secretDAL,
|
||||
secretImportDAL,
|
||||
folderDAL,
|
||||
webhookDAL,
|
||||
projectEnvDAL,
|
||||
orgDAL,
|
||||
smtpService,
|
||||
projectDAL,
|
||||
projectMembershipDAL
|
||||
}: TSecretQueueFactoryDep) => {
|
||||
const syncIntegrations = async (dto: TGetSecrets) => {
|
||||
await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, {
|
||||
attempts: 5,
|
||||
delay: 1000,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: {
|
||||
count: 5 // keep the most recent jobs
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const syncSecrets = async (dto: TGetSecrets) => {
|
||||
await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, dto, {
|
||||
jobId: `secret-webhook-${dto.environment}-${dto.projectId}-${dto.secretPath}`,
|
||||
removeOnFail: { count: 5 },
|
||||
removeOnComplete: true,
|
||||
delay: 1000,
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
}
|
||||
});
|
||||
await syncIntegrations(dto);
|
||||
};
|
||||
|
||||
const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => {
|
||||
const appCfg = getConfig();
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.SecretReminder,
|
||||
QueueJobs.SecretReminder,
|
||||
{
|
||||
// on prod it this will be in days, in development this will be second
|
||||
every:
|
||||
appCfg.NODE_ENV === "development"
|
||||
? secondsToMillis(dto.repeatDays)
|
||||
: daysToMillisecond(dto.repeatDays)
|
||||
},
|
||||
`reminder-${dto.secretId}`
|
||||
);
|
||||
};
|
||||
|
||||
const addSecretReminder = async ({
|
||||
oldSecret,
|
||||
newSecret,
|
||||
projectId
|
||||
}: TCreateSecretReminderDTO) => {
|
||||
try {
|
||||
const appCfg = getConfig();
|
||||
|
||||
if (oldSecret.id !== newSecret.id) {
|
||||
throw new BadRequestError({
|
||||
name: "SecretReminderIdMismatch",
|
||||
message: "Existing secret didn't match the updated secret ID."
|
||||
});
|
||||
}
|
||||
|
||||
if (!newSecret.secretReminderRepeatDays) {
|
||||
throw new BadRequestError({
|
||||
name: "SecretReminderRepeatDaysMissing",
|
||||
message: "Secret reminder repeat days is missing."
|
||||
});
|
||||
}
|
||||
|
||||
// If the secret already has a reminder, we should remove the existing one first.
|
||||
if (oldSecret.secretReminderRepeatDays) {
|
||||
await removeSecretReminder({
|
||||
repeatDays: oldSecret.secretReminderRepeatDays,
|
||||
secretId: oldSecret.id
|
||||
});
|
||||
}
|
||||
|
||||
await queueService.queue(
|
||||
QueueName.SecretReminder,
|
||||
QueueJobs.SecretReminder,
|
||||
{
|
||||
note: newSecret.secretReminderNote,
|
||||
projectId,
|
||||
repeatDays: newSecret.secretReminderRepeatDays,
|
||||
secretId: newSecret.id
|
||||
},
|
||||
{
|
||||
jobId: `reminder-${newSecret.id}`,
|
||||
repeat: {
|
||||
// on prod it this will be in days, in development this will be second
|
||||
every:
|
||||
appCfg.NODE_ENV === "development"
|
||||
? secondsToMillis(newSecret.secretReminderRepeatDays)
|
||||
: daysToMillisecond(newSecret.secretReminderRepeatDays),
|
||||
immediately: true
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(err, "Failed to create secret reminder.");
|
||||
throw new BadRequestError({
|
||||
name: "SecretReminderCreateFailed",
|
||||
message: "Failed to create secret reminder."
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => {
|
||||
const { secretReminderRepeatDays, secretReminderNote } = newSecret;
|
||||
|
||||
if (newSecret.type !== "personal" && secretReminderRepeatDays !== undefined) {
|
||||
if (
|
||||
(secretReminderRepeatDays &&
|
||||
oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) ||
|
||||
(secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote)
|
||||
) {
|
||||
await addSecretReminder({
|
||||
oldSecret,
|
||||
newSecret,
|
||||
projectId
|
||||
});
|
||||
} else if (
|
||||
secretReminderRepeatDays === null &&
|
||||
secretReminderNote === null &&
|
||||
oldSecret.secretReminderRepeatDays
|
||||
) {
|
||||
await removeSecretReminder({
|
||||
secretId: oldSecret.id,
|
||||
repeatDays: oldSecret.secretReminderRepeatDays
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getIntegrationSecrets = async (dto: TGetSecrets & { folderId: string }, key: string) => {
|
||||
const secrets = await secretDAL.findByFolderId(dto.folderId);
|
||||
if (!secrets.length) return {};
|
||||
|
||||
// get imported secrets
|
||||
const secretImport = await secretImportDAL.find({ folderId: dto.folderId });
|
||||
const importedSecrets = await fnSecretsFromImports({
|
||||
allowedImports: secretImport,
|
||||
secretDAL,
|
||||
folderDAL
|
||||
});
|
||||
const content: Record<
|
||||
string,
|
||||
{ value: string; comment?: string; skipMultilineEncoding?: boolean }
|
||||
> = {};
|
||||
|
||||
importedSecrets.forEach(({ secrets: secs }) => {
|
||||
secs.forEach((secret) => {
|
||||
const secretKey = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
const secretValue = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key
|
||||
});
|
||||
content[secretKey] = { value: secretValue };
|
||||
content[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding);
|
||||
|
||||
if (secret.secretCommentCiphertext && secret.secretCommentIV && secret.secretCommentTag) {
|
||||
const commentValue = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretCommentCiphertext,
|
||||
iv: secret.secretCommentIV,
|
||||
tag: secret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
content[secretKey].comment = commentValue;
|
||||
}
|
||||
});
|
||||
});
|
||||
secrets.forEach((secret) => {
|
||||
const secretKey = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
content[secretKey] = { value: secretValue };
|
||||
|
||||
if (secret.secretCommentCiphertext && secret.secretCommentIV && secret.secretCommentTag) {
|
||||
const commentValue = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: secret.secretCommentCiphertext,
|
||||
iv: secret.secretCommentIV,
|
||||
tag: secret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
content[secretKey].comment = commentValue;
|
||||
}
|
||||
|
||||
content[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding);
|
||||
});
|
||||
const expandSecrets = interpolateSecrets({
|
||||
projectId: dto.projectId,
|
||||
secretEncKey: key,
|
||||
folderDAL,
|
||||
secretDAL
|
||||
});
|
||||
await expandSecrets(content);
|
||||
return content;
|
||||
};
|
||||
|
||||
queueService.start(QueueName.IntegrationSync, async (job) => {
|
||||
const { environment, projectId, secretPath } = job.data;
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) {
|
||||
logger.error("Secret path not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const integrations = await integrationDAL.findByProjectIdV2(projectId, environment);
|
||||
const toBeSyncedIntegrations = integrations.filter(
|
||||
({ secretPath: integrationSecPath, isActive }) =>
|
||||
isActive && isSamePath(secretPath, integrationSecPath)
|
||||
);
|
||||
|
||||
if (!integrations.length) return;
|
||||
logger.info("Secret integration sync started", job.data, job.id);
|
||||
for (const integration of toBeSyncedIntegrations) {
|
||||
const integrationAuth = {
|
||||
...integration.integrationAuth,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: integration.projectId
|
||||
};
|
||||
|
||||
const botKey = await projectBotService.getBotKey(projectId);
|
||||
const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken(
|
||||
integrationAuth,
|
||||
botKey
|
||||
);
|
||||
const secrets = await getIntegrationSecrets(
|
||||
{ environment, projectId, secretPath, folderId: folder.id },
|
||||
botKey
|
||||
);
|
||||
const suffixedSecrets: typeof secrets = {};
|
||||
const metadata = integration.metadata as Record<string, any>;
|
||||
if (metadata) {
|
||||
Object.keys(secrets).forEach((key) => {
|
||||
const prefix = metadata?.secretPrefix || "";
|
||||
const suffix = metadata?.secretSuffix || "";
|
||||
const newKey = prefix + key + suffix;
|
||||
suffixedSecrets[newKey] = secrets[key];
|
||||
});
|
||||
}
|
||||
|
||||
await syncIntegrationSecrets({
|
||||
integration,
|
||||
integrationAuth,
|
||||
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets,
|
||||
accessId: accessId as string,
|
||||
accessToken,
|
||||
appendices: {
|
||||
prefix: metadata?.secretPrefix || "",
|
||||
suffix: metadata?.secretSuffix || ""
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("Secret integration sync ended", job.id);
|
||||
});
|
||||
|
||||
queueService.start(QueueName.SecretReminder, async ({ data }) => {
|
||||
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}]`);
|
||||
|
||||
const { projectId } = data;
|
||||
|
||||
const organization = await orgDAL.findOrgByProjectId(projectId);
|
||||
const project = await projectDAL.findById(projectId);
|
||||
|
||||
if (!organization) {
|
||||
logger.info(
|
||||
`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
logger.info(
|
||||
`secretReminderQueue.process: [secretDocument=${data.secretId}] no project found`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
|
||||
|
||||
if (!projectMembers || !projectMembers.length) {
|
||||
logger.info(
|
||||
`secretReminderQueue.process: [secretDocument=${data.secretId}] no project members found`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.SecretReminder,
|
||||
subjectLine: "Infisical secret reminder",
|
||||
recipients: [...projectMembers.map((m) => m.user.email)],
|
||||
substitutions: {
|
||||
reminderNote: data.note, // May not be present.
|
||||
projectName: project.name,
|
||||
organizationName: organization.name
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
queueService.listen(QueueName.IntegrationSync, "failed", (job, err) => {
|
||||
logger.error("Failed to sync integration", job?.data, err);
|
||||
});
|
||||
|
||||
queueService.start(QueueName.SecretWebhook, async (job) => {
|
||||
await fnTriggerWebhook({ ...job.data, projectEnvDAL, webhookDAL });
|
||||
});
|
||||
|
||||
return {
|
||||
syncSecrets,
|
||||
syncIntegrations,
|
||||
addSecretReminder,
|
||||
removeSecretReminder,
|
||||
handleSecretReminder
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import {
|
||||
SecretType,
|
||||
TSecretBlindIndexes,
|
||||
TSecrets,
|
||||
TSecretsInsert,
|
||||
TSecretsUpdate
|
||||
} from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote">;
|
||||
|
||||
type TPartialInputSecret = Pick<
|
||||
TSecrets,
|
||||
"type" | "secretReminderNote" | "secretReminderRepeatDays" | "id"
|
||||
>;
|
||||
|
||||
export type TCreateSecretDTO = {
|
||||
secretName: string;
|
||||
path: string;
|
||||
environment: string;
|
||||
type: "shared" | "personal";
|
||||
secretKeyCiphertext: string;
|
||||
secretKeyIV: string;
|
||||
secretKeyTag: string;
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
secretCommentCiphertext?: string;
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretReminderRepeatDays?: number | null;
|
||||
secretReminderNote?: string | null;
|
||||
tags?: string[];
|
||||
metadata?: {
|
||||
source?: string;
|
||||
};
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TUpdateSecretDTO = {
|
||||
secretName: string;
|
||||
path: string;
|
||||
newSecretName?: string;
|
||||
environment: string;
|
||||
type: "shared" | "personal";
|
||||
secretKeyCiphertext?: string;
|
||||
secretKeyIV?: string;
|
||||
secretKeyTag?: string;
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
tags?: string[];
|
||||
secretCommentCiphertext?: string;
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretReminderRepeatDays?: number | null;
|
||||
secretReminderNote?: string | null;
|
||||
metadata?: {
|
||||
source?: string;
|
||||
};
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TDeleteSecretDTO = {
|
||||
secretName: string;
|
||||
secretId?: string;
|
||||
path: string;
|
||||
environment: string;
|
||||
type: "shared" | "personal";
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetSecretsDTO = {
|
||||
path: string;
|
||||
environment: string;
|
||||
includeImports?: boolean;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetASecretDTO = {
|
||||
secretName: string;
|
||||
path: string;
|
||||
environment: string;
|
||||
type: "shared" | "personal";
|
||||
includeImports?: boolean;
|
||||
version?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TCreateBulkSecretDTO = {
|
||||
path: string;
|
||||
environment: string;
|
||||
secrets: Array<{
|
||||
secretName: string;
|
||||
secretKeyCiphertext: string;
|
||||
secretKeyIV: string;
|
||||
secretKeyTag: string;
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
tags?: string[];
|
||||
secretValueTag: string;
|
||||
secretCommentCiphertext?: string;
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
metadata?: {
|
||||
source?: string;
|
||||
};
|
||||
}>;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TUpdateBulkSecretDTO = {
|
||||
path: string;
|
||||
environment: string;
|
||||
secrets: Array<{
|
||||
type: SecretType;
|
||||
secretName: string;
|
||||
newSecretName?: string;
|
||||
secretValueCiphertext?: string;
|
||||
secretValueIV?: string;
|
||||
secretValueTag?: string;
|
||||
tags?: string[];
|
||||
secretCommentCiphertext?: string;
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretReminderRepeatDays?: number | null;
|
||||
secretReminderNote?: string | null;
|
||||
}>;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TDeleteBulkSecretDTO = {
|
||||
path: string;
|
||||
environment: string;
|
||||
secrets: Array<{
|
||||
type: SecretType;
|
||||
secretName: string;
|
||||
}>;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TListSecretVersionDTO = {
|
||||
secretId: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetSecretsRawDTO = {
|
||||
path: string;
|
||||
environment: string;
|
||||
includeImports?: boolean;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetASecretRawDTO = {
|
||||
secretName: string;
|
||||
path: string;
|
||||
environment: string;
|
||||
type: "shared" | "personal";
|
||||
includeImports?: boolean;
|
||||
version?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TCreateSecretRawDTO = TProjectPermission & {
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretName: string;
|
||||
secretValue: string;
|
||||
type: SecretType;
|
||||
secretComment?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
};
|
||||
|
||||
export type TUpdateSecretRawDTO = TProjectPermission & {
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretName: string;
|
||||
secretValue?: string;
|
||||
type: SecretType;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretReminderRepeatDays?: number | null;
|
||||
secretReminderNote?: string | null;
|
||||
};
|
||||
|
||||
export type TDeleteSecretRawDTO = TProjectPermission & {
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretName: string;
|
||||
type: SecretType;
|
||||
};
|
||||
|
||||
export type TGetSecretVersionsDTO = Omit<TProjectPermission, "projectId"> & {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
secretId: string;
|
||||
};
|
||||
|
||||
export type TFnSecretBulkInsert = {
|
||||
folderId: string;
|
||||
tx?: Knex;
|
||||
inputSecrets: Array<Omit<TSecretsInsert, "folderId"> & { tags?: string[] }>;
|
||||
};
|
||||
|
||||
export type TFnSecretBulkUpdate = {
|
||||
folderId: string;
|
||||
projectId: string;
|
||||
inputSecrets: { filter: Partial<TSecrets>; data: TSecretsUpdate & { tags?: string[] } }[];
|
||||
tx?: Knex;
|
||||
};
|
||||
|
||||
export type TFnSecretBulkDelete = {
|
||||
folderId: string;
|
||||
projectId: string;
|
||||
inputSecrets: Array<{ type: SecretType; secretBlindIndex: string }>;
|
||||
actorId: string;
|
||||
tx?: Knex;
|
||||
};
|
||||
|
||||
export type TFnSecretBlindIndexCheck = {
|
||||
folderId: string;
|
||||
userId?: string;
|
||||
blindIndexCfg: TSecretBlindIndexes;
|
||||
inputSecrets: Array<{ secretName: string; type?: SecretType }>;
|
||||
isNew: boolean;
|
||||
};
|
||||
|
||||
// when blind index is already present
|
||||
export type TFnSecretBlindIndexCheckV2 = {
|
||||
folderId: string;
|
||||
userId?: string;
|
||||
inputSecrets: Array<{ secretBlindIndex: string; type?: SecretType }>;
|
||||
};
|
||||
|
||||
export type THandleReminderDTO = {
|
||||
newSecret: TPartialInputSecret;
|
||||
oldSecret: TPartialSecret;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TCreateSecretReminderDTO = {
|
||||
oldSecret: TPartialSecret;
|
||||
newSecret: TPartialSecret;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TRemoveSecretReminderDTO = {
|
||||
secretId: string;
|
||||
repeatDays: number;
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TSecretVersions } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TSecretVersionDALFactory = ReturnType<typeof secretVersionDALFactory>;
|
||||
|
||||
export const secretVersionDALFactory = (db: TDbClient) => {
|
||||
const secretVersionOrm = ormify(db, TableName.SecretVersion);
|
||||
|
||||
// This will fetch all latest secret versions from a folder
|
||||
const findLatestVersionByFolderId = async (folderId: string, tx?: Knex) => {
|
||||
try {
|
||||
const docs = await (tx || db)(TableName.SecretVersion)
|
||||
.where(`${TableName.SecretVersion}.folderId`, folderId)
|
||||
.join(TableName.Secret, `${TableName.Secret}.id`, `${TableName.SecretVersion}.secretId`)
|
||||
.join<TSecretVersions, TSecretVersions & { secretId: string; max: number }>(
|
||||
(tx || db)(TableName.SecretVersion)
|
||||
.groupBy("folderId", "secretId")
|
||||
.max("version")
|
||||
.select("secretId")
|
||||
.as("latestVersion"),
|
||||
(bd) => {
|
||||
bd.on(`${TableName.SecretVersion}.secretId`, "latestVersion.secretId").andOn(
|
||||
`${TableName.SecretVersion}.version`,
|
||||
"latestVersion.max"
|
||||
);
|
||||
}
|
||||
)
|
||||
.select(selectAllTableCols(TableName.SecretVersion));
|
||||
return docs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindLatestVersionByFolderId" });
|
||||
}
|
||||
};
|
||||
|
||||
const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => {
|
||||
try {
|
||||
const docs: Array<TSecretVersions & { max: number }> = await (tx || db)(
|
||||
TableName.SecretVersion
|
||||
)
|
||||
.where("folderId", folderId)
|
||||
.whereIn(`${TableName.SecretVersion}.secretId`, secretIds)
|
||||
.join(
|
||||
(tx || db)(TableName.SecretVersion)
|
||||
.groupBy("secretId")
|
||||
.max("version")
|
||||
.select("secretId")
|
||||
.as("latestVersion"),
|
||||
(bd) => {
|
||||
bd.on(`${TableName.SecretVersion}.secretId`, "latestVersion.secretId").andOn(
|
||||
`${TableName.SecretVersion}.version`,
|
||||
"latestVersion.max"
|
||||
);
|
||||
}
|
||||
);
|
||||
return docs.reduce<Record<string, TSecretVersions>>(
|
||||
(prev, curr) => ({ ...prev, [curr.secretId || ""]: curr }),
|
||||
{}
|
||||
);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindLatestVersinMany" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretVersionOrm, findLatestVersionMany, findLatestVersionByFolderId };
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TSecretVersionTagDALFactory = ReturnType<typeof secretVersionTagDALFactory>;
|
||||
|
||||
export const secretVersionTagDALFactory = (db: TDbClient) => {
|
||||
const secretVersionTagDAL = ormify(db, TableName.SecretVersionTag);
|
||||
return secretVersionTagDAL;
|
||||
};
|
||||
Reference in New Issue
Block a user