diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 9a011bc5f..c42dcb28b 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -1,7 +1,8 @@ import to from 'await-to-js'; import { Types } from 'mongoose'; import { Request, Response } from 'express'; -import { ISecret, Membership, Secret, Workspace } from '../../models'; +import { ISecret, Secret } from '../../models'; +import { IAction } from '../../ee/models'; import { SECRET_PERSONAL, SECRET_SHARED, @@ -20,6 +21,252 @@ import { ABILITY_READ, ABILITY_WRITE } from '../../variables/organization'; import { userHasNoAbility, userHasWorkspaceAccess, userHasWriteOnlyAbility } from '../../ee/helpers/checkMembershipPermissions'; import Tag from '../../models/tag'; import _ from 'lodash'; +import { + BatchSecretRequest, + BatchSecret +} from '../../types/secret'; + +/** + * Peform a batch of any specified CUD secret operations + * @param req + * @param res + */ +export const batchSecrets = async (req: Request, res: Response) => { + const channel = getChannelFromUserAgent(req.headers['user-agent']); + const { + workspaceId, + environment, + requests + }: { + workspaceId: string; + environment: string; + requests: BatchSecretRequest[]; + }= req.body; + + const createSecrets: BatchSecret[] = []; + const updateSecrets: BatchSecret[] = []; + const deleteSecrets: Types.ObjectId[] = []; + const actions: IAction[] = []; + + requests.forEach((request) => { + switch (request.method) { + case 'POST': + createSecrets.push({ + ...request.secret, + version: 1, + user: request.secret.type === SECRET_PERSONAL ? req.user : undefined, + environment, + workspace: new Types.ObjectId(workspaceId) + }); + break; + case 'PATCH': + updateSecrets.push({ + ...request.secret, + _id: new Types.ObjectId(request.secret._id) + }); + break; + case 'DELETE': + deleteSecrets.push(new Types.ObjectId(request.secret._id)); + break; + } + }); + + // handle create secrets + let createdSecrets: ISecret[] = []; + if (createSecrets.length > 0) { + createdSecrets = await Secret.insertMany(createSecrets); + // (EE) add secret versions for new secrets + await EESecretService.addSecretVersions({ + secretVersions: createdSecrets.map((n: any) => { + return ({ + ...n._doc, + _id: new Types.ObjectId(), + secret: n._id, + isDeleted: false + }); + }) + }); + + const addAction = await EELogService.createAction({ + name: ACTION_ADD_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: createdSecrets.map((n) => n._id) + }) as IAction; + actions.push(addAction); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets added', + distinctId: req.user.email, + properties: { + numberOfSecrets: createdSecrets.length, + environment, + workspaceId, + channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + // handle update secrets + let updatedSecrets: ISecret[] = []; + if (updateSecrets.length > 0 && req.secrets) { + // construct object containing all secrets + let listedSecretsObj: { + [key: string]: { + version: number; + type: string; + } + } = {}; + + listedSecretsObj = req.secrets.reduce((obj: any, secret: ISecret) => ({ + ...obj, + [secret._id.toString()]: secret + }), {}); + + const updateOperations = updateSecrets.map((u) => ({ + updateOne: { + filter: { _id: new Types.ObjectId(u._id) }, + update: { + $inc: { + version: 1 + }, + ...u, + _id: new Types.ObjectId(u._id) + } + } + })); + + await Secret.bulkWrite(updateOperations); + + const secretVersions = updateSecrets.map((u) => ({ + secret: new Types.ObjectId(u._id), + version: listedSecretsObj[u._id.toString()].version, + workspace: new Types.ObjectId(workspaceId), + type: listedSecretsObj[u._id.toString()].type, + environment, + isDeleted: false, + secretKeyCiphertext: u.secretKeyCiphertext, + secretKeyIV: u.secretKeyIV, + secretKeyTag: u.secretKeyTag, + secretValueCiphertext: u.secretValueCiphertext, + secretValueIV: u.secretValueIV, + secretValueTag: u.secretValueTag, + secretCommentCiphertext: u.secretCommentCiphertext, + secretCommentIV: u.secretCommentIV, + secretCommentTag: u.secretCommentTag, + tags: u.tags + })); + + await EESecretService.addSecretVersions({ + secretVersions + }); + + updatedSecrets = await Secret.find({ + _id: { + $in: updateSecrets.map((u) => new Types.ObjectId(u._id)) + } + }); + + const updateAction = await EELogService.createAction({ + name: ACTION_UPDATE_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: updatedSecrets.map((u) => u._id) + }) as IAction; + actions.push(updateAction); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets modified', + distinctId: req.user.email, + properties: { + numberOfSecrets: updateSecrets.length, + environment, + workspaceId, + channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + // handle delete secrets + if (deleteSecrets.length > 0) { + await Secret.deleteMany({ + _id: { + $in: deleteSecrets + } + }); + + await EESecretService.markDeletedSecretVersions({ + secretIds: deleteSecrets + }); + + const deleteAction = await EELogService.createAction({ + name: ACTION_DELETE_SECRETS, + userId: req.user._id, + workspaceId: new Types.ObjectId(workspaceId), + secretIds: deleteSecrets + }) as IAction; + actions.push(deleteAction); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: deleteSecrets.length, + environment, + workspaceId, + channel: channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + } + + if (actions.length > 0) { + // (EE) create (audit) log + await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: new Types.ObjectId(workspaceId), + actions, + channel, + ipAddress: req.ip + }); + } + + // // trigger event - push secrets + await EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId + }) + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + const resObj: { [key: string]: ISecret[] | string[] } = {} + + if (createSecrets.length > 0) { + resObj['createdSecrets'] = createdSecrets; + } + + if (updateSecrets.length > 0) { + resObj['updatedSecrets'] = updatedSecrets; + } + + if (deleteSecrets.length > 0) { + resObj['deletedSecrets'] = deleteSecrets.map((d) => d.toString()); + } + + return res.status(200).send(resObj); +} /** * Create secret(s) for workspace with id [workspaceId] and environment [environment] @@ -166,11 +413,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash, secretCommentCiphertext, secretCommentIV, secretCommentTag, @@ -187,11 +432,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash, secretCommentCiphertext, secretCommentIV, secretCommentTag, diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index 562c8aa88..e1aca670f 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -158,11 +158,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash } = oldSecretVersion; // update secret @@ -179,11 +177,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash }, { new: true @@ -204,11 +200,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyCiphertext, secretKeyIV, secretKeyTag, - secretKeyHash, secretValueCiphertext, secretValueIV, - secretValueTag, - secretValueHash + secretValueTag }).save(); // take secret snapshot diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index efa042765..3095b52fb 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -5,22 +5,19 @@ import { } from '../../variables'; export interface ISecretVersion { - _id: Types.ObjectId; secret: Types.ObjectId; version: number; workspace: Types.ObjectId; // new type: string; // new - user: Types.ObjectId; // new + user?: Types.ObjectId; // new environment: string; // new isDeleted: boolean; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; - secretKeyHash: string; secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; - secretValueHash: string; tags?: string[]; } @@ -72,9 +69,6 @@ const secretVersionSchema = new Schema( type: String, // symmetric required: true }, - secretKeyHash: { - type: String - }, secretValueCiphertext: { type: String, required: true @@ -87,9 +81,6 @@ const secretVersionSchema = new Schema( type: String, // symmetric required: true }, - secretValueHash: { - type: String - }, tags: { ref: 'Tag', type: [Schema.Types.ObjectId], diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index 9c5577d2f..51629b32d 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -6,14 +6,52 @@ import { requireSecretsAuth, validateRequest } from '../../middleware'; -import { query, check, body } from 'express-validator'; +import { query, body } from 'express-validator'; import { secretsController } from '../../controllers/v2'; +import { validateSecrets } from '../../helpers/secret'; import { ADMIN, MEMBER, SECRET_PERSONAL, SECRET_SHARED } from '../../variables'; +import { + BatchSecretRequest +} from '../../types/secret'; + +router.post( + '/batch', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim(), + body('requests') + .exists() + .custom(async (requests: BatchSecretRequest[], { req }) => { + if (Array.isArray(requests)) { + const secretIds = requests + .map((request) => request.secret._id) + .filter((secretId) => secretId !== undefined) + + if (secretIds.length > 0) { + const relevantSecrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds + }); + + req.secrets = relevantSecrets; + } + } + return true; + }), + validateRequest, + secretsController.batchSecrets +); router.post( '/', diff --git a/backend/src/types/secret/index.d.ts b/backend/src/types/secret/index.d.ts index 177df8c0f..257038f60 100644 --- a/backend/src/types/secret/index.d.ts +++ b/backend/src/types/secret/index.d.ts @@ -1,5 +1,7 @@ +import { Types } from 'mongoose'; import { Assign, Omit } from 'utility-types'; import { ISecret } from '../../models'; +import { mongo } from 'mongoose'; // Everything is required, except the omitted types export type CreateSecretRequestBody = Omit; @@ -12,3 +14,39 @@ export type SanitizedSecretModify = Partial; + +export interface BatchSecretRequest { + id: string; + method: 'POST' | 'PATCH' | 'DELETE'; + secret: Secret; +} + +export interface BatchSecret { + _id: string; + type: 'shared' | 'personal', + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + tags: string[]; +} + +export interface BatchSecret { + _id: string; + type: 'shared' | 'personal', + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + tags: string[]; +} \ No newline at end of file diff --git a/frontend/src/components/basic/Toggle.tsx b/frontend/src/components/basic/Toggle.tsx index 9c6dcf155..313d7009c 100644 --- a/frontend/src/components/basic/Toggle.tsx +++ b/frontend/src/components/basic/Toggle.tsx @@ -3,8 +3,8 @@ import { Switch } from '@headlessui/react'; interface ToggleProps { enabled: boolean; setEnabled: (value: boolean) => void; - addOverride: (value: string | undefined, pos: number) => void; - pos: number; + addOverride: (value: string | undefined, id: string) => void; + id: string; } /** @@ -13,18 +13,18 @@ interface ToggleProps { * @param {boolean} obj.enabled - whether the toggle is turned on or off * @param {function} obj.setEnabled - change the state of the toggle * @param {function} obj.addOverride - a function that adds an override to a certain secret - * @param {number} obj.pos - position of a certain secret + * @param {number} obj.id - id of a certain secret * @returns */ -const Toggle = ({ enabled, setEnabled, addOverride, pos }: ToggleProps): JSX.Element => { +const Toggle = ({ enabled, setEnabled, addOverride, id }: ToggleProps): JSX.Element => { return ( { if (enabled === false) { - addOverride('', pos); + addOverride('', id); } else { - addOverride(undefined, pos); + addOverride(undefined, id); } setEnabled(!enabled); }} diff --git a/frontend/src/components/basic/dialog/DeleteEnvVar.tsx b/frontend/src/components/basic/dialog/DeleteEnvVar.tsx index 48aa93c11..6daed29fd 100644 --- a/frontend/src/components/basic/dialog/DeleteEnvVar.tsx +++ b/frontend/src/components/basic/dialog/DeleteEnvVar.tsx @@ -45,19 +45,19 @@ export const DeleteEnvVar = ({ isOpen, onClose, onSubmit }: Props) => { leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - - + + {t('dashboard:sidebar.delete-key-dialog.title')}
-

+

{t('dashboard:sidebar.delete-key-dialog.confirm-delete-message')}

diff --git a/frontend/src/components/dashboard/CommentField.tsx b/frontend/src/components/dashboard/CommentField.tsx index c6552bb8f..3308fdf1b 100644 --- a/frontend/src/components/dashboard/CommentField.tsx +++ b/frontend/src/components/dashboard/CommentField.tsx @@ -6,11 +6,11 @@ import { useTranslation } from 'next-i18next'; const CommentField = ({ comment, modifyComment, - position + id }: { comment: string; - modifyComment: (value: string, posistion: number) => void; - position: number; + modifyComment: (value: string, id: string) => void; + id: string; }) => { const { t } = useTranslation(); @@ -20,7 +20,7 @@ const CommentField = ({