You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1094 lines
30 KiB
1094 lines
30 KiB
import { Types } from "mongoose";
|
|
import {
|
|
CreateSecretParams,
|
|
DeleteSecretParams,
|
|
GetSecretParams,
|
|
GetSecretsParams,
|
|
UpdateSecretParams
|
|
} from "../interfaces/services/SecretService";
|
|
import {
|
|
ISecret,
|
|
IServiceTokenData,
|
|
Secret,
|
|
SecretBlindIndexData,
|
|
ServiceTokenData
|
|
} from "../models";
|
|
import { SecretVersion } from "../ee/models";
|
|
import {
|
|
BadRequestError,
|
|
InternalServerError,
|
|
SecretBlindIndexDataNotFoundError,
|
|
SecretNotFoundError,
|
|
UnauthorizedRequestError
|
|
} from "../utils/errors";
|
|
import {
|
|
ACTION_ADD_SECRETS,
|
|
ACTION_DELETE_SECRETS,
|
|
ACTION_READ_SECRETS,
|
|
ACTION_UPDATE_SECRETS,
|
|
ALGORITHM_AES_256_GCM,
|
|
ENCODING_SCHEME_BASE64,
|
|
ENCODING_SCHEME_UTF8,
|
|
SECRET_PERSONAL,
|
|
SECRET_SHARED
|
|
} from "../variables";
|
|
import crypto from "crypto";
|
|
import * as argon2 from "argon2";
|
|
import {
|
|
decryptSymmetric128BitHexKeyUTF8,
|
|
encryptSymmetric128BitHexKeyUTF8
|
|
} from "../utils/crypto";
|
|
import { TelemetryService } from "../services";
|
|
import { client, getEncryptionKey, getRootEncryptionKey } from "../config";
|
|
import { EELogService, EESecretService } from "../ee/services";
|
|
import { getAuthDataPayloadIdObj, getAuthDataPayloadUserObj } from "../utils/auth";
|
|
import { getFolderByPath, getFolderIdFromServiceToken } from "../services/FolderService";
|
|
import picomatch from "picomatch";
|
|
import path from "path";
|
|
import Folder, { TFolderRootSchema } from "../models/folder";
|
|
|
|
export const isValidScope = (
|
|
authPayload: IServiceTokenData,
|
|
environment: string,
|
|
secretPath: string
|
|
) => {
|
|
const { scopes: tkScopes } = authPayload;
|
|
const validScope = tkScopes.find(
|
|
(scope) =>
|
|
picomatch.isMatch(secretPath, scope.secretPath, { strictSlashes: false }) &&
|
|
scope.environment === environment
|
|
);
|
|
|
|
return Boolean(validScope);
|
|
};
|
|
|
|
export function containsGlobPatterns(secretPath: string) {
|
|
const globChars = ["*", "?", "[", "]", "{", "}", "**"];
|
|
const normalizedPath = path.normalize(secretPath);
|
|
return globChars.some((char) => normalizedPath.includes(char));
|
|
}
|
|
|
|
/**
|
|
* Returns an object containing secret [secret] but with its value, key, comment decrypted.
|
|
*
|
|
* Precondition: the workspace for secret [secret] must have E2EE disabled
|
|
* @param {ISecret} secret - secret to repackage to raw
|
|
* @param {String} key - symmetric key to use to decrypt secret
|
|
* @returns
|
|
*/
|
|
export const repackageSecretToRaw = ({ secret, key }: { secret: ISecret; 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 {
|
|
_id: secret._id,
|
|
version: secret.version,
|
|
workspace: secret.workspace,
|
|
type: secret.type,
|
|
environment: secret.environment,
|
|
user: secret.user,
|
|
secretKey,
|
|
secretValue,
|
|
secretComment
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Create secret blind index data containing encrypted blind index [salt]
|
|
* for workspace with id [workspaceId]
|
|
* @param {Object} obj
|
|
* @param {Types.ObjectId} obj.workspaceId
|
|
*/
|
|
export const createSecretBlindIndexDataHelper = async ({
|
|
workspaceId
|
|
}: {
|
|
workspaceId: Types.ObjectId;
|
|
}) => {
|
|
// initialize random blind index salt for workspace
|
|
const salt = crypto.randomBytes(16).toString("base64");
|
|
|
|
const encryptionKey = await getEncryptionKey();
|
|
const rootEncryptionKey = await getRootEncryptionKey();
|
|
|
|
if (rootEncryptionKey) {
|
|
const {
|
|
ciphertext: encryptedSaltCiphertext,
|
|
iv: saltIV,
|
|
tag: saltTag
|
|
} = client.encryptSymmetric(salt, rootEncryptionKey);
|
|
|
|
return await new SecretBlindIndexData({
|
|
workspace: workspaceId,
|
|
encryptedSaltCiphertext,
|
|
saltIV,
|
|
saltTag,
|
|
algorithm: ALGORITHM_AES_256_GCM,
|
|
keyEncoding: ENCODING_SCHEME_BASE64
|
|
}).save();
|
|
} else {
|
|
const {
|
|
ciphertext: encryptedSaltCiphertext,
|
|
iv: saltIV,
|
|
tag: saltTag
|
|
} = encryptSymmetric128BitHexKeyUTF8({
|
|
plaintext: salt,
|
|
key: encryptionKey
|
|
});
|
|
|
|
return await new SecretBlindIndexData({
|
|
workspace: workspaceId,
|
|
encryptedSaltCiphertext,
|
|
saltIV,
|
|
saltTag,
|
|
algorithm: ALGORITHM_AES_256_GCM,
|
|
keyEncoding: ENCODING_SCHEME_UTF8
|
|
}).save();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get secret blind index salt for workspace with id [workspaceId]
|
|
* @param {Object} obj
|
|
* @param {Types.ObjectId} obj.workspaceId - id of workspace to get salt for
|
|
* @returns
|
|
*/
|
|
export const getSecretBlindIndexSaltHelper = async ({
|
|
workspaceId
|
|
}: {
|
|
workspaceId: Types.ObjectId;
|
|
}) => {
|
|
const encryptionKey = await getEncryptionKey();
|
|
const rootEncryptionKey = await getRootEncryptionKey();
|
|
|
|
const secretBlindIndexData = await SecretBlindIndexData.findOne({
|
|
workspace: workspaceId
|
|
}).select("+algorithm +keyEncoding");
|
|
|
|
if (!secretBlindIndexData) throw SecretBlindIndexDataNotFoundError();
|
|
|
|
if (rootEncryptionKey && secretBlindIndexData.keyEncoding === ENCODING_SCHEME_BASE64) {
|
|
return client.decryptSymmetric(
|
|
secretBlindIndexData.encryptedSaltCiphertext,
|
|
rootEncryptionKey,
|
|
secretBlindIndexData.saltIV,
|
|
secretBlindIndexData.saltTag
|
|
);
|
|
} else if (encryptionKey && secretBlindIndexData.keyEncoding === ENCODING_SCHEME_UTF8) {
|
|
// decrypt workspace salt
|
|
return decryptSymmetric128BitHexKeyUTF8({
|
|
ciphertext: secretBlindIndexData.encryptedSaltCiphertext,
|
|
iv: secretBlindIndexData.saltIV,
|
|
tag: secretBlindIndexData.saltTag,
|
|
key: encryptionKey
|
|
});
|
|
}
|
|
|
|
throw InternalServerError({
|
|
message: "Failed to obtain workspace salt needed for secret blind indexing"
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Generate blind index for secret with name [secretName]
|
|
* and salt [salt]
|
|
* @param {Object} obj
|
|
* @param {String} obj.secretName - name of secret to generate blind index for
|
|
* @param {String} obj.salt - base64-salt
|
|
*/
|
|
export const generateSecretBlindIndexWithSaltHelper = async ({
|
|
secretName,
|
|
salt
|
|
}: {
|
|
secretName: string;
|
|
salt: string;
|
|
}) => {
|
|
// generate secret blind index
|
|
const secretBlindIndex = (
|
|
await argon2.hash(secretName, {
|
|
type: argon2.argon2id,
|
|
salt: Buffer.from(salt, "base64"),
|
|
saltLength: 16, // default 16 bytes
|
|
memoryCost: 65536, // default pool of 64 MiB per thread.
|
|
hashLength: 32,
|
|
parallelism: 1,
|
|
raw: true
|
|
})
|
|
).toString("base64");
|
|
|
|
return secretBlindIndex;
|
|
};
|
|
|
|
/**
|
|
* Generate blind index for secret with name [secretName]
|
|
* for workspace with id [workspaceId]
|
|
* @param {Object} obj
|
|
* @param {Stringj} obj.secretName - name of secret to generate blind index for
|
|
* @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to
|
|
*/
|
|
export const generateSecretBlindIndexHelper = async ({
|
|
secretName,
|
|
workspaceId
|
|
}: {
|
|
secretName: string;
|
|
workspaceId: Types.ObjectId;
|
|
}) => {
|
|
// check if workspace blind index data exists
|
|
const encryptionKey = await getEncryptionKey();
|
|
const rootEncryptionKey = await getRootEncryptionKey();
|
|
|
|
const secretBlindIndexData = await SecretBlindIndexData.findOne({
|
|
workspace: workspaceId
|
|
}).select("+algorithm +keyEncoding");
|
|
|
|
if (!secretBlindIndexData) throw SecretBlindIndexDataNotFoundError();
|
|
|
|
let salt;
|
|
if (rootEncryptionKey && secretBlindIndexData.keyEncoding === ENCODING_SCHEME_BASE64) {
|
|
salt = client.decryptSymmetric(
|
|
secretBlindIndexData.encryptedSaltCiphertext,
|
|
rootEncryptionKey,
|
|
secretBlindIndexData.saltIV,
|
|
secretBlindIndexData.saltTag
|
|
);
|
|
|
|
const secretBlindIndex = await generateSecretBlindIndexWithSaltHelper({
|
|
secretName,
|
|
salt
|
|
});
|
|
|
|
return secretBlindIndex;
|
|
} else if (encryptionKey && secretBlindIndexData.keyEncoding === ENCODING_SCHEME_UTF8) {
|
|
// decrypt workspace salt
|
|
salt = decryptSymmetric128BitHexKeyUTF8({
|
|
ciphertext: secretBlindIndexData.encryptedSaltCiphertext,
|
|
iv: secretBlindIndexData.saltIV,
|
|
tag: secretBlindIndexData.saltTag,
|
|
key: encryptionKey
|
|
});
|
|
|
|
const secretBlindIndex = await generateSecretBlindIndexWithSaltHelper({
|
|
secretName,
|
|
salt
|
|
});
|
|
|
|
return secretBlindIndex;
|
|
}
|
|
|
|
throw InternalServerError({
|
|
message: "Failed to generate secret blind index"
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Create secret with name [secretName]
|
|
* @param {Object} obj
|
|
* @param {String} obj.secretName - name of secret to create
|
|
* @param {Types.ObjectId} obj.workspaceId - id of workspace to create secret for
|
|
* @param {String} obj.environment - environment in workspace to create secret for
|
|
* @param {'shared' | 'personal'} obj.type - type of secret
|
|
* @param {AuthData} obj.authData - authentication data on request
|
|
* @returns
|
|
*/
|
|
export const createSecretHelper = async ({
|
|
secretName,
|
|
workspaceId,
|
|
environment,
|
|
type,
|
|
authData,
|
|
secretKeyCiphertext,
|
|
secretKeyIV,
|
|
secretKeyTag,
|
|
secretValueCiphertext,
|
|
secretValueIV,
|
|
secretValueTag,
|
|
secretCommentCiphertext,
|
|
secretCommentIV,
|
|
secretCommentTag,
|
|
secretPath = "/"
|
|
}: CreateSecretParams) => {
|
|
const secretBlindIndex = await generateSecretBlindIndexHelper({
|
|
secretName,
|
|
workspaceId: new Types.ObjectId(workspaceId)
|
|
});
|
|
|
|
// if using service token filter towards the folderId by secretpath
|
|
if (authData.authPayload instanceof ServiceTokenData) {
|
|
if (!isValidScope(authData.authPayload, environment, secretPath)) {
|
|
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
|
}
|
|
}
|
|
const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath);
|
|
|
|
const exists = await Secret.exists({
|
|
secretBlindIndex,
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
folder: folderId,
|
|
type,
|
|
environment,
|
|
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
|
|
});
|
|
|
|
if (exists)
|
|
throw BadRequestError({
|
|
message: "Failed to create secret that already exists"
|
|
});
|
|
|
|
if (type === SECRET_PERSONAL) {
|
|
// case: secret type is personal -> check if a corresponding shared secret
|
|
// with the same blind index [secretBlindIndex] exists
|
|
|
|
const exists = await Secret.exists({
|
|
secretBlindIndex,
|
|
folder: folderId,
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
type: SECRET_SHARED
|
|
});
|
|
|
|
if (!exists)
|
|
throw BadRequestError({
|
|
message: "Failed to create personal secret override for no corresponding shared secret"
|
|
});
|
|
}
|
|
|
|
// create secret
|
|
const secret = await new Secret({
|
|
version: 1,
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
type,
|
|
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}),
|
|
secretBlindIndex,
|
|
secretKeyCiphertext,
|
|
secretKeyIV,
|
|
secretKeyTag,
|
|
secretValueCiphertext,
|
|
secretValueIV,
|
|
secretValueTag,
|
|
secretCommentCiphertext,
|
|
secretCommentIV,
|
|
secretCommentTag,
|
|
folder: folderId,
|
|
algorithm: ALGORITHM_AES_256_GCM,
|
|
keyEncoding: ENCODING_SCHEME_UTF8
|
|
}).save();
|
|
|
|
const secretVersion = new SecretVersion({
|
|
secret: secret._id,
|
|
version: secret.version,
|
|
workspace: secret.workspace,
|
|
type,
|
|
folder: folderId,
|
|
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}),
|
|
environment: secret.environment,
|
|
isDeleted: false,
|
|
secretBlindIndex,
|
|
secretKeyCiphertext,
|
|
secretKeyIV,
|
|
secretKeyTag,
|
|
secretValueCiphertext,
|
|
secretValueIV,
|
|
secretValueTag,
|
|
algorithm: ALGORITHM_AES_256_GCM,
|
|
keyEncoding: ENCODING_SCHEME_UTF8
|
|
});
|
|
|
|
// (EE) add version for new secret
|
|
await EESecretService.addSecretVersions({
|
|
secretVersions: [secretVersion]
|
|
});
|
|
|
|
// (EE) create (audit) log
|
|
const action = await EELogService.createAction({
|
|
name: ACTION_ADD_SECRETS,
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
secretIds: [secret._id]
|
|
});
|
|
|
|
action &&
|
|
(await EELogService.createLog({
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
actions: [action],
|
|
channel: authData.authChannel,
|
|
ipAddress: authData.authIP
|
|
}));
|
|
|
|
// (EE) take a secret snapshot
|
|
await EESecretService.takeSecretSnapshot({
|
|
workspaceId,
|
|
environment,
|
|
folderId
|
|
});
|
|
|
|
const postHogClient = await TelemetryService.getPostHogClient();
|
|
|
|
if (postHogClient) {
|
|
postHogClient.capture({
|
|
event: "secrets added",
|
|
distinctId: await TelemetryService.getDistinctId({
|
|
authData
|
|
}),
|
|
properties: {
|
|
numberOfSecrets: 1,
|
|
environment,
|
|
workspaceId,
|
|
folderId,
|
|
channel: authData.authChannel,
|
|
userAgent: authData.authUserAgent
|
|
}
|
|
});
|
|
}
|
|
|
|
return secret;
|
|
};
|
|
|
|
/**
|
|
* Get secrets for workspace with id [workspaceId] and environment [environment]
|
|
* @param {Object} obj
|
|
* @param {Types.ObjectId} obj.workspaceId - id of workspace
|
|
* @param {String} obj.environment - environment in workspace
|
|
* @param {AuthData} obj.authData - authentication data on request
|
|
* @returns
|
|
*/
|
|
export const getSecretsHelper = async ({
|
|
workspaceId,
|
|
environment,
|
|
authData,
|
|
secretPath = "/"
|
|
}: GetSecretsParams) => {
|
|
let secrets: ISecret[] = [];
|
|
// if using service token filter towards the folderId by secretpath
|
|
if (authData.authPayload instanceof ServiceTokenData) {
|
|
if (!isValidScope(authData.authPayload, environment, secretPath)) {
|
|
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
|
}
|
|
}
|
|
const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath);
|
|
|
|
// get personal secrets first
|
|
secrets = await Secret.find({
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
folder: folderId,
|
|
type: SECRET_PERSONAL,
|
|
...getAuthDataPayloadUserObj(authData)
|
|
})
|
|
.populate("tags")
|
|
.lean();
|
|
|
|
// concat with shared secrets
|
|
secrets = secrets.concat(
|
|
await Secret.find({
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
folder: folderId,
|
|
type: SECRET_SHARED,
|
|
secretBlindIndex: {
|
|
$nin: secrets.map((secret) => secret.secretBlindIndex)
|
|
}
|
|
})
|
|
.populate("tags")
|
|
.lean()
|
|
);
|
|
|
|
// (EE) create (audit) log
|
|
const action = await EELogService.createAction({
|
|
name: ACTION_READ_SECRETS,
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
secretIds: secrets.map((secret) => secret._id)
|
|
});
|
|
|
|
action &&
|
|
(await EELogService.createLog({
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
actions: [action],
|
|
channel: authData.authChannel,
|
|
ipAddress: authData.authIP
|
|
}));
|
|
|
|
const postHogClient = await TelemetryService.getPostHogClient();
|
|
|
|
if (postHogClient) {
|
|
postHogClient.capture({
|
|
event: "secrets pulled",
|
|
distinctId: await TelemetryService.getDistinctId({
|
|
authData
|
|
}),
|
|
properties: {
|
|
numberOfSecrets: secrets.length,
|
|
environment,
|
|
workspaceId,
|
|
folderId,
|
|
channel: authData.authChannel,
|
|
userAgent: authData.authUserAgent
|
|
}
|
|
});
|
|
}
|
|
|
|
return secrets;
|
|
};
|
|
|
|
/**
|
|
* Get secret with name [secretName]
|
|
* @param {Object} obj
|
|
* @param {String} obj.secretName - name of secret to get
|
|
* @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to
|
|
* @param {String} obj.environment - environment in workspace that secret belongs to
|
|
* @param {'shared' | 'personal'} obj.type - type of secret
|
|
* @param {AuthData} obj.authData - authentication data on request
|
|
* @returns
|
|
*/
|
|
export const getSecretHelper = async ({
|
|
secretName,
|
|
workspaceId,
|
|
environment,
|
|
type,
|
|
authData,
|
|
secretPath = "/"
|
|
}: GetSecretParams) => {
|
|
const secretBlindIndex = await generateSecretBlindIndexHelper({
|
|
secretName,
|
|
workspaceId: new Types.ObjectId(workspaceId)
|
|
});
|
|
let secret: ISecret | null = null;
|
|
// if using service token filter towards the folderId by secretpath
|
|
if (authData.authPayload instanceof ServiceTokenData) {
|
|
if (!isValidScope(authData.authPayload, environment, secretPath)) {
|
|
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
|
}
|
|
}
|
|
const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath);
|
|
|
|
// try getting personal secret first (if exists)
|
|
secret = await Secret.findOne({
|
|
secretBlindIndex,
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
folder: folderId,
|
|
type: type ?? SECRET_PERSONAL,
|
|
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
|
|
}).lean();
|
|
|
|
if (!secret) {
|
|
// case: failed to find personal secret matching criteria
|
|
// -> find shared secret matching criteria
|
|
secret = await Secret.findOne({
|
|
secretBlindIndex,
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
folder: folderId,
|
|
type: SECRET_SHARED
|
|
}).lean();
|
|
}
|
|
|
|
if (!secret) throw SecretNotFoundError();
|
|
|
|
// (EE) create (audit) log
|
|
const action = await EELogService.createAction({
|
|
name: ACTION_READ_SECRETS,
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
secretIds: [secret._id]
|
|
});
|
|
|
|
action &&
|
|
(await EELogService.createLog({
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
actions: [action],
|
|
channel: authData.authChannel,
|
|
ipAddress: authData.authIP
|
|
}));
|
|
|
|
const postHogClient = await TelemetryService.getPostHogClient();
|
|
|
|
if (postHogClient) {
|
|
postHogClient.capture({
|
|
event: "secrets pull",
|
|
distinctId: await TelemetryService.getDistinctId({
|
|
authData
|
|
}),
|
|
properties: {
|
|
numberOfSecrets: 1,
|
|
environment,
|
|
workspaceId,
|
|
folderId,
|
|
channel: authData.authChannel,
|
|
userAgent: authData.authUserAgent
|
|
}
|
|
});
|
|
}
|
|
|
|
return secret;
|
|
};
|
|
|
|
/**
|
|
* Update secret with name [secretName]
|
|
* @param {Object} obj
|
|
* @param {String} obj.secretName - name of secret to update
|
|
* @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to
|
|
* @param {String} obj.environment - environment in workspace that secret belongs to
|
|
* @param {'shared' | 'personal'} obj.type - type of secret
|
|
* @param {String} obj.secretValueCiphertext - ciphertext of secret value
|
|
* @param {String} obj.secretValueIV - IV of secret value
|
|
* @param {String} obj.secretValueTag - tag of secret value
|
|
* @param {AuthData} obj.authData - authentication data on request
|
|
* @returns
|
|
*/
|
|
|
|
export const updateSecretHelper = async ({
|
|
secretName,
|
|
workspaceId,
|
|
environment,
|
|
type,
|
|
authData,
|
|
secretValueCiphertext,
|
|
secretValueIV,
|
|
secretValueTag,
|
|
secretPath
|
|
}: UpdateSecretParams) => {
|
|
const secretBlindIndex = await generateSecretBlindIndexHelper({
|
|
secretName,
|
|
workspaceId: new Types.ObjectId(workspaceId)
|
|
});
|
|
|
|
let secret: ISecret | null = null;
|
|
// if using service token filter towards the folderId by secretpath
|
|
if (authData.authPayload instanceof ServiceTokenData) {
|
|
if (!isValidScope(authData.authPayload, environment, secretPath)) {
|
|
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
|
}
|
|
}
|
|
const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath);
|
|
|
|
if (type === SECRET_SHARED) {
|
|
// case: update shared secret
|
|
secret = await Secret.findOneAndUpdate(
|
|
{
|
|
secretBlindIndex,
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
folder: folderId,
|
|
type
|
|
},
|
|
{
|
|
secretValueCiphertext,
|
|
secretValueIV,
|
|
secretValueTag,
|
|
$inc: { version: 1 }
|
|
},
|
|
{
|
|
new: true
|
|
}
|
|
);
|
|
} else {
|
|
// case: update personal secret
|
|
|
|
secret = await Secret.findOneAndUpdate(
|
|
{
|
|
secretBlindIndex,
|
|
workspace: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
type,
|
|
folder: folderId,
|
|
...getAuthDataPayloadUserObj(authData)
|
|
},
|
|
{
|
|
secretValueCiphertext,
|
|
secretValueIV,
|
|
secretValueTag,
|
|
$inc: { version: 1 }
|
|
},
|
|
{
|
|
new: true
|
|
}
|
|
);
|
|
}
|
|
|
|
if (!secret) throw SecretNotFoundError();
|
|
|
|
const secretVersion = new SecretVersion({
|
|
secret: secret._id,
|
|
version: secret.version,
|
|
workspace: secret.workspace,
|
|
folder: folderId,
|
|
type,
|
|
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}),
|
|
environment: secret.environment,
|
|
isDeleted: false,
|
|
secretBlindIndex,
|
|
secretKeyCiphertext: secret.secretKeyCiphertext,
|
|
secretKeyIV: secret.secretKeyIV,
|
|
secretKeyTag: secret.secretKeyTag,
|
|
secretValueCiphertext,
|
|
secretValueIV,
|
|
secretValueTag,
|
|
algorithm: ALGORITHM_AES_256_GCM,
|
|
keyEncoding: ENCODING_SCHEME_UTF8
|
|
});
|
|
|
|
// (EE) add version for new secret
|
|
await EESecretService.addSecretVersions({
|
|
secretVersions: [secretVersion]
|
|
});
|
|
|
|
// (EE) create (audit) log
|
|
const action = await EELogService.createAction({
|
|
name: ACTION_UPDATE_SECRETS,
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
secretIds: [secret._id]
|
|
});
|
|
|
|
action &&
|
|
(await EELogService.createLog({
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
actions: [action],
|
|
channel: authData.authChannel,
|
|
ipAddress: authData.authIP
|
|
}));
|
|
|
|
// (EE) take a secret snapshot
|
|
await EESecretService.takeSecretSnapshot({
|
|
workspaceId,
|
|
environment,
|
|
folderId: secret?.folder
|
|
});
|
|
|
|
const postHogClient = await TelemetryService.getPostHogClient();
|
|
|
|
if (postHogClient) {
|
|
postHogClient.capture({
|
|
event: "secrets modified",
|
|
distinctId: await TelemetryService.getDistinctId({
|
|
authData
|
|
}),
|
|
properties: {
|
|
numberOfSecrets: 1,
|
|
environment,
|
|
workspaceId,
|
|
folderId,
|
|
channel: authData.authChannel,
|
|
userAgent: authData.authUserAgent
|
|
}
|
|
});
|
|
}
|
|
|
|
return secret;
|
|
};
|
|
|
|
/**
|
|
* Delete secret with name [secretName]
|
|
* @param {Object} obj
|
|
* @param {String} obj.secretName - name of secret to delete
|
|
* @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to
|
|
* @param {String} obj.environment - environment in workspace that secret belongs to
|
|
* @param {'shared' | 'personal'} obj.type - type of secret
|
|
* @param {AuthData} obj.authData - authentication data on request
|
|
* @returns
|
|
*/
|
|
export const deleteSecretHelper = async ({
|
|
secretName,
|
|
workspaceId,
|
|
environment,
|
|
type,
|
|
authData,
|
|
secretPath = "/"
|
|
}: DeleteSecretParams) => {
|
|
const secretBlindIndex = await generateSecretBlindIndexHelper({
|
|
secretName,
|
|
workspaceId: new Types.ObjectId(workspaceId)
|
|
});
|
|
|
|
// if using service token filter towards the folderId by secretpath
|
|
if (authData.authPayload instanceof ServiceTokenData) {
|
|
if (!isValidScope(authData.authPayload, environment, secretPath)) {
|
|
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
|
}
|
|
}
|
|
const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath);
|
|
|
|
let secrets: ISecret[] = [];
|
|
let secret: ISecret | null = null;
|
|
|
|
if (type === SECRET_SHARED) {
|
|
secrets = await Secret.find({
|
|
secretBlindIndex,
|
|
workspaceId: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
folder: folderId
|
|
}).lean();
|
|
|
|
secret = await Secret.findOneAndDelete({
|
|
secretBlindIndex,
|
|
workspaceId: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
type,
|
|
folder: folderId
|
|
}).lean();
|
|
|
|
await Secret.deleteMany({
|
|
secretBlindIndex,
|
|
workspaceId: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
folder: folderId
|
|
});
|
|
} else {
|
|
secret = await Secret.findOneAndDelete({
|
|
secretBlindIndex,
|
|
folder: folderId,
|
|
workspaceId: new Types.ObjectId(workspaceId),
|
|
environment,
|
|
type,
|
|
...getAuthDataPayloadUserObj(authData)
|
|
}).lean();
|
|
|
|
if (secret) {
|
|
secrets = [secret];
|
|
}
|
|
}
|
|
|
|
if (!secret) throw SecretNotFoundError();
|
|
|
|
await EESecretService.markDeletedSecretVersions({
|
|
secretIds: secrets.map((secret) => secret._id)
|
|
});
|
|
|
|
// (EE) create (audit) log
|
|
const action = await EELogService.createAction({
|
|
name: ACTION_DELETE_SECRETS,
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
secretIds: secrets.map((secret) => secret._id)
|
|
});
|
|
|
|
action &&
|
|
(await EELogService.createLog({
|
|
...getAuthDataPayloadIdObj(authData),
|
|
workspaceId,
|
|
actions: [action],
|
|
channel: authData.authChannel,
|
|
ipAddress: authData.authIP
|
|
}));
|
|
|
|
// (EE) take a secret snapshot
|
|
await EESecretService.takeSecretSnapshot({
|
|
workspaceId,
|
|
environment,
|
|
folderId: secret?.folder
|
|
});
|
|
|
|
const postHogClient = await TelemetryService.getPostHogClient();
|
|
|
|
if (postHogClient) {
|
|
postHogClient.capture({
|
|
event: "secrets deleted",
|
|
distinctId: await TelemetryService.getDistinctId({
|
|
authData
|
|
}),
|
|
properties: {
|
|
numberOfSecrets: secrets.length,
|
|
environment,
|
|
workspaceId,
|
|
folderId,
|
|
channel: authData.authChannel,
|
|
userAgent: authData.authUserAgent
|
|
}
|
|
});
|
|
}
|
|
|
|
return {
|
|
secrets,
|
|
secret
|
|
};
|
|
};
|
|
|
|
const fetchSecretsCrossEnv = (workspaceId: string, folders: TFolderRootSchema[], key: string) => {
|
|
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];
|
|
}
|
|
|
|
let folderId = "root";
|
|
const folder = folders.find(({ environment }) => environment === secRefEnv);
|
|
if (!folder && secRefPathUrl !== "/") {
|
|
throw BadRequestError({ message: "Folder not found" });
|
|
}
|
|
|
|
if (folder) {
|
|
const selectedFolder = getFolderByPath(folder.nodes, secRefPathUrl);
|
|
if (!selectedFolder) {
|
|
throw BadRequestError({ message: "Folder not found" });
|
|
}
|
|
folderId = selectedFolder.id;
|
|
}
|
|
|
|
const secrets = await Secret.find({
|
|
workspace: workspaceId,
|
|
environment: secRefEnv,
|
|
type: SECRET_SHARED,
|
|
folder: folderId
|
|
});
|
|
|
|
const decryptedSec = secrets.reduce<Record<string, string>>((prev, 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
|
|
});
|
|
|
|
prev[secretKey] = secretValue;
|
|
return prev;
|
|
}, {});
|
|
|
|
fetchCache[uniqKey] = decryptedSec;
|
|
|
|
return fetchCache[uniqKey][secRefKey];
|
|
};
|
|
};
|
|
|
|
const INTERPOLATION_SYNTAX_REG = new RegExp(/\${([^}]+)}/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]) {
|
|
return expandedSec[key];
|
|
}
|
|
if (recursionChainBreaker?.[key]) {
|
|
return "";
|
|
}
|
|
recursionChainBreaker[key] = true;
|
|
|
|
let interpolatedValue = interpolatedSec[key];
|
|
if (!interpolatedValue) {
|
|
throw new Error(`Couldn't find referenced value - ${key}`);
|
|
}
|
|
|
|
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);
|
|
}
|
|
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);
|
|
interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val);
|
|
}
|
|
}
|
|
}
|
|
|
|
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")}"`;
|
|
};
|
|
|
|
export const expandSecrets = async (
|
|
workspaceId: string,
|
|
rootEncKey: string,
|
|
secrets: Record<string, { value: string; comment?: string }>
|
|
) => {
|
|
const expandedSec: Record<string, string> = {};
|
|
const interpolatedSec: Record<string, string> = {};
|
|
|
|
const folders = await Folder.find({ workspace: workspaceId });
|
|
const crossSecEnvFetch = fetchSecretsCrossEnv(workspaceId, folders, rootEncKey);
|
|
|
|
Object.keys(secrets).forEach((key) => {
|
|
if (secrets[key].value.match(INTERPOLATION_SYNTAX_REG)) {
|
|
console.log("KEY that matches ====>", key)
|
|
interpolatedSec[key] = secrets[key].value;
|
|
} else {
|
|
expandedSec[key] = secrets[key].value;
|
|
}
|
|
});
|
|
|
|
for (const key of Object.keys(secrets)) {
|
|
if (expandedSec?.[key]) {
|
|
secrets[key].value = formatMultiValueEnv(expandedSec[key]);
|
|
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
|
|
);
|
|
|
|
secrets[key].value = formatMultiValueEnv(expandedVal);
|
|
}
|
|
|
|
return secrets;
|
|
};
|