diff --git a/backend/src/app.ts b/backend/src/app.ts index 608d5b828..aa7ac9b28 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -42,6 +42,7 @@ import { } from './routes/v1'; import { secret as v2SecretRouter, + secrets as v2SecretsRouter, workspace as v2WorkspaceRouter, serviceTokenData as v2ServiceTokenDataRouter, apiKeyData as v2APIKeyDataRouter, @@ -95,16 +96,17 @@ app.use('/api/v1/membership', v1MembershipRouter); app.use('/api/v1/key', v1KeyRouter); app.use('/api/v1/invite-org', v1InviteOrgRouter); app.use('/api/v1/secret', v1SecretRouter); -app.use('/api/v1/service-token', v1ServiceTokenRouter); // deprecate +app.use('/api/v1/service-token', v1ServiceTokenRouter); // stop supporting app.use('/api/v1/password', v1PasswordRouter); app.use('/api/v1/stripe', v1StripeRouter); app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); // v2 routes -app.use('/api/v2/workspace', v2WorkspaceRouter); -app.use('/api/v2/secret', v2SecretRouter); -app.use('/api/v2/service-token', v2ServiceTokenDataRouter); +app.use('/api/v2/workspace', v2WorkspaceRouter); // TODO: turn into plural route +app.use('/api/v2/secret', v2SecretRouter); // stop supporting, TODO: revise +app.use('/api/v2/secrets', v2SecretsRouter); +app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route app.use('/api/v2/api-key-data', v2APIKeyDataRouter); // api docs diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index 5628cda1a..f3703b889 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -115,13 +115,14 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { if (!membershipOrg) { throw new Error('Failed to validate organization membership'); } - + invitee = await User.findOne({ email: inviteeEmail - }); + }).select('+publicKey'); if (invitee) { // case: invitee is an existing user + inviteeMembershipOrg = await MembershipOrg.findOne({ user: invitee._id, organization: organizationId diff --git a/backend/src/controllers/v2/apiKeyDataController.ts b/backend/src/controllers/v2/apiKeyDataController.ts index 3aacde8af..cafbacb5b 100644 --- a/backend/src/controllers/v2/apiKeyDataController.ts +++ b/backend/src/controllers/v2/apiKeyDataController.ts @@ -65,7 +65,6 @@ export const createAPIKeyData = async (req: Request, res: Response) => { apiKey = `ak.${apiKeyData._id.toString()}.${secret}`; } catch (err) { - console.error(err); Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); return res.status(400).send({ diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 2c5cce660..1651c09ee 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -2,10 +2,12 @@ import * as workspaceController from './workspaceController'; import * as serviceTokenDataController from './serviceTokenDataController'; import * as apiKeyDataController from './apiKeyDataController'; import * as secretController from './secretController'; +import * as secretsController from './secretsController'; export { workspaceController, serviceTokenDataController, apiKeyDataController, - secretController + secretController, + secretsController } diff --git a/backend/src/controllers/v2/secretController.ts b/backend/src/controllers/v2/secretController.ts index 3bb9f1720..b993d3811 100644 --- a/backend/src/controllers/v2/secretController.ts +++ b/backend/src/controllers/v2/secretController.ts @@ -7,12 +7,16 @@ const { ValidationError } = mongoose.Error; import { BadRequestError, InternalServerError, UnauthorizedRequestError, ValidationError as RouteValidationError } from '../../utils/errors'; import { AnyBulkWriteOperation } from 'mongodb'; import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; -import { validateMembership } from "../../helpers/membership"; -import { ADMIN, MEMBER } from '../../variables'; +import { postHogClient } from '../../services'; -export const createSingleSecret = async (req: Request, res: Response) => { +/** + * Create secret for workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const createSecret = async (req: Request, res: Response) => { const secretToCreate: CreateSecretRequestBody = req.body.secret; - const { workspaceId, environmentName } = req.params + const { workspaceId, environment } = req.params const sanitizedSecret: SanitizedSecretForCreate = { secretKeyCiphertext: secretToCreate.secretKeyCiphertext, secretKeyIV: secretToCreate.secretKeyIV, @@ -27,23 +31,44 @@ export const createSingleSecret = async (req: Request, res: Response) => { secretCommentTag: secretToCreate.secretCommentTag, secretCommentHash: secretToCreate.secretCommentHash, workspace: new Types.ObjectId(workspaceId), - environment: environmentName, + environment, type: secretToCreate.type, user: new Types.ObjectId(req.user._id) } - const [error, newlyCreatedSecret] = await to(Secret.create(sanitizedSecret).then()) + const [error, secret] = await to(Secret.create(sanitizedSecret).then()) if (error instanceof ValidationError) { throw RouteValidationError({ message: error.message, stack: error.stack }) } - res.status(200).send() + if (postHogClient) { + postHogClient.capture({ + event: 'secrets added', + distinctId: req.user.email, + properties: { + numberOfSecrets: 1, + workspaceId, + environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + + res.status(200).send({ + secret + }) } -export const batchCreateSecrets = async (req: Request, res: Response) => { +/** + * Create many secrets for workspace wiht id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const createSecrets = async (req: Request, res: Response) => { const secretsToCreate: CreateSecretRequestBody[] = req.body.secrets; - const { workspaceId, environmentName } = req.params + const { workspaceId, environment } = req.params const sanitizedSecretesToCreate: SanitizedSecretForCreate[] = [] secretsToCreate.forEach(rawSecret => { @@ -61,7 +86,7 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { secretCommentTag: rawSecret.secretCommentTag, secretCommentHash: rawSecret.secretCommentHash, workspace: new Types.ObjectId(workspaceId), - environment: environmentName, + environment, type: rawSecret.type, user: new Types.ObjectId(req.user._id) } @@ -69,7 +94,7 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { sanitizedSecretesToCreate.push(safeUpdateFields) }) - const [bulkCreateError, newlyCreatedSecrets] = await to(Secret.insertMany(sanitizedSecretesToCreate).then()) + const [bulkCreateError, secrets] = await to(Secret.insertMany(sanitizedSecretesToCreate).then()) if (bulkCreateError) { if (bulkCreateError instanceof ValidationError) { throw RouteValidationError({ message: bulkCreateError.message, stack: bulkCreateError.stack }) @@ -78,10 +103,31 @@ export const batchCreateSecrets = async (req: Request, res: Response) => { throw InternalServerError({ message: "Unable to process your batch create request. Please try again", stack: bulkCreateError.stack }) } - res.status(200).send() + if (postHogClient) { + postHogClient.capture({ + event: 'secrets added', + distinctId: req.user.email, + properties: { + numberOfSecrets: (secretsToCreate ?? []).length, + workspaceId, + environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + + res.status(200).send({ + secrets + }) } -export const batchDeleteSecrets = async (req: Request, res: Response) => { +/** + * Delete secrets in workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const deleteSecrets = async (req: Request, res: Response) => { const { workspaceId, environmentName } = req.params const secretIdsToDelete: string[] = req.body.secretIds @@ -93,10 +139,12 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => { const secretsUserCanDeleteSet: Set = new Set(secretIdsUserCanDelete.map(objectId => objectId._id.toString())); const deleteOperationsToPerform: AnyBulkWriteOperation[] = [] + let numSecretsDeleted = 0; secretIdsToDelete.forEach(secretIdToDelete => { if (secretsUserCanDeleteSet.has(secretIdToDelete)) { const deleteOperation = { deleteOne: { filter: { _id: new Types.ObjectId(secretIdToDelete) } } } deleteOperationsToPerform.push(deleteOperation) + numSecretsDeleted++; } else { throw RouteValidationError({ message: "You cannot delete secrets that you do not have access to" }) } @@ -110,37 +158,57 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => { throw InternalServerError() } + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: numSecretsDeleted, + environment: environmentName, + workspaceId, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + res.status(200).send() } -export const deleteSingleSecret = async (req: Request, res: Response) => { - const { secretId } = req.params; +/** + * Delete secret with id [secretId] + * @param req + * @param res + */ +export const deleteSecret = async (req: Request, res: Response) => { + await Secret.findByIdAndDelete(req._secret._id) - const [error, singleSecretRetrieved] = await to(Secret.findById(secretId).then()) - if (error instanceof ValidationError) { - throw RouteValidationError({ message: "Unable to get secret, please try again", stack: error.stack }) + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: 1, + workspaceId: req._secret.workspace.toString(), + environment: req._secret.environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); } - if (singleSecretRetrieved) { - const [membershipValidationError, membership] = await to(validateMembership({ - userId: req.user._id, - workspaceId: singleSecretRetrieved.workspace._id.toString(), - acceptedRoles: [ADMIN, MEMBER] - })) - - if (membershipValidationError || !membership) { - throw UnauthorizedRequestError() - } - - await Secret.findByIdAndDelete(secretId) - - res.status(200).send() - } else { - throw BadRequestError() - } + res.status(200).send({ + secret: req._secret + }) } -export const batchModifySecrets = async (req: Request, res: Response) => { +/** + * Update secrets for workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + * @returns + */ +export const updateSecrets = async (req: Request, res: Response) => { const { workspaceId, environmentName } = req.params const secretsModificationsRequested: ModifySecretRequestBody[] = req.body.secrets; const [secretIdsUserCanModifyError, secretIdsUserCanModify] = await to(Secret.find({ workspace: workspaceId, environment: environmentName }, { _id: 1 }).then()) @@ -184,10 +252,30 @@ export const batchModifySecrets = async (req: Request, res: Response) => { throw InternalServerError() } + if (postHogClient) { + postHogClient.capture({ + event: 'secrets modified', + distinctId: req.user.email, + properties: { + numberOfSecrets: (secretsModificationsRequested ?? []).length, + environment: environmentName, + workspaceId, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + return res.status(200).send() } -export const modifySingleSecrets = async (req: Request, res: Response) => { +/** + * Update a secret within workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + * @returns + */ +export const updateSecret = async (req: Request, res: Response) => { const { workspaceId, environmentName } = req.params const secretModificationsRequested: ModifySecretRequestBody = req.body.secret; @@ -216,14 +304,35 @@ export const modifySingleSecrets = async (req: Request, res: Response) => { throw RouteValidationError({ message: "Unable to apply modifications, please try again", stack: error.stack }) } + if (postHogClient) { + postHogClient.capture({ + event: 'secrets modified', + distinctId: req.user.email, + properties: { + numberOfSecrets: 1, + environment: environmentName, + workspaceId, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + return res.status(200).send(singleModificationUpdate) } -export const fetchAllSecrets = async (req: Request, res: Response) => { +/** + * Return secrets for workspace with id [workspaceId], environment [environment] and user + * with id [req.user._id] + * @param req + * @param res + * @returns + */ +export const getSecrets = async (req: Request, res: Response) => { const { environment } = req.query; const { workspaceId } = req.params; - let userId: string | undefined = undefined // Used for choosing the personal secrets to fetch in + let userId: string | undefined = undefined // used for getting personal secrets for user if (req.user) { userId = req.user._id.toString(); } @@ -232,7 +341,7 @@ export const fetchAllSecrets = async (req: Request, res: Response) => { userId = req.serviceTokenData.user._id } - const [retriveAllSecretsError, allSecrets] = await to(Secret.find( + const [err, secrets] = await to(Secret.find( { workspace: workspaceId, environment, @@ -241,36 +350,49 @@ export const fetchAllSecrets = async (req: Request, res: Response) => { } ).then()) - if (retriveAllSecretsError instanceof ValidationError) { - throw RouteValidationError({ message: "Unable to get secrets, please try again", stack: retriveAllSecretsError.stack }) + if (err) { + throw RouteValidationError({ message: "Failed to get secrets, please try again", stack: err.stack }) } - return res.json(allSecrets) + if (postHogClient) { + postHogClient.capture({ + event: 'secrets pulled', + distinctId: req.user.email, + properties: { + numberOfSecrets: (secrets ?? []).length, + environment, + workspaceId, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + + return res.json(secrets) } -export const fetchSingleSecret = async (req: Request, res: Response) => { - const { secretId } = req.params; - - const [error, singleSecretRetrieved] = await to(Secret.findById(secretId).then()) - - if (error instanceof ValidationError) { - throw RouteValidationError({ message: "Unable to get secret, please try again", stack: error.stack }) - } - - if (singleSecretRetrieved) { - const [membershipValidationError, membership] = await to(validateMembership({ - userId: req.user._id, - workspaceId: singleSecretRetrieved.workspace._id.toString(), - acceptedRoles: [ADMIN, MEMBER] - })) - - if (membershipValidationError || !membership) { - throw UnauthorizedRequestError() - } - - res.json(singleSecretRetrieved) - - } else { - throw BadRequestError() +/** + * Return secret with id [secretId] + * @param req + * @param res + * @returns + */ +export const getSecret = async (req: Request, res: Response) => { + if (postHogClient) { + postHogClient.capture({ + event: 'secrets pulled', + distinctId: req.user.email, + properties: { + numberOfSecrets: 1, + workspaceId: req._secret.workspace.toString(), + environment: req._secret.environment, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); } + + return res.status(200).send({ + secret: req._secret + }); } \ No newline at end of file diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts new file mode 100644 index 000000000..69cacd75c --- /dev/null +++ b/backend/src/controllers/v2/secretsController.ts @@ -0,0 +1,451 @@ +import to from 'await-to-js'; +import { Types } from 'mongoose'; +import { Request, Response } from 'express'; +import { ISecret, Secret } from '../../models'; +import { + SECRET_PERSONAL, + SECRET_SHARED, + ACTION_ADD_SECRETS, + ACTION_READ_SECRETS, + ACTION_UPDATE_SECRETS, + ACTION_DELETE_SECRETS +} from '../../variables'; +import { ValidationError } from '../../utils/errors'; +import { EESecretService, EELogService } from '../../ee/services'; +import { postHogClient } from '../../services'; +import { BadRequestError } from '../../utils/errors'; + +/** + * Create secret(s) for workspace with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const createSecrets = async (req: Request, res: Response) => { + const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; + const { workspaceId, environment } = req.body; + + let toAdd; + if (Array.isArray(req.body.secrets)) { + // case: create multiple secrets + toAdd = req.body.secrets; + } else if (typeof req.body.secrets === 'object') { + // case: create 1 secret + toAdd = [req.body.secrets]; + } + + const newSecrets = await Secret.insertMany( + toAdd.map(({ + type, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + }: { + type: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + }) => ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + type, + user: type === SECRET_PERSONAL ? req.user : undefined, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag + })) + ); + + // (EE) add secret versions for new secrets + EESecretService.addSecretVersions({ + secretVersions: newSecrets.map(({ + _id, + version, + workspace, + type, + user, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }) => ({ + _id: new Types.ObjectId(), + secret: _id, + version, + workspace, + type, + user, + environment, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + })) + }); + + const addAction = await EELogService.createActionSecret({ + name: ACTION_ADD_SECRETS, + userId: req.user._id.toString(), + workspaceId, + secretIds: newSecrets.map((n) => n._id) + }); + + // (EE) create (audit) log + addAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId, + actions: [addAction], + channel, + ipAddress: req.ip + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets added', + distinctId: req.user.email, + properties: { + numberOfSecrets: toAdd.length, + environment, + workspaceId, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + + return res.status(200).send({ + secrets: newSecrets + }); +} + +/** + * Return secret(s) for workspace with id [workspaceId], environment [environment] and user + * with id [req.user._id] + * @param req + * @param res + * @returns + */ +export const getSecrets = async (req: Request, res: Response) => { + const { workspaceId, environment } = req.query; + + let userId: Types.ObjectId | undefined = undefined // used for getting personal secrets for user + if (req.user) { + userId = req.user._id; + } + + if (req.serviceTokenData) { + userId = req.serviceTokenData.user._id + } + + const [err, secrets] = await to(Secret.find( + { + workspace: workspaceId, + environment, + $or: [ + { user: userId }, + { user: { $exists: false } } + ], + type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } + } + ).then()) + + if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack }); + + const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; + + const readAction = await EELogService.createActionSecret({ + name: ACTION_READ_SECRETS, + userId: req.user._id.toString(), + workspaceId: workspaceId as string, + secretIds: secrets.map((n: any) => n._id) + }); + + readAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: workspaceId as string, + actions: [readAction], + channel, + ipAddress: req.ip + }); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + channel, + userAgent: req.headers?.['user-agent'] + } + }); + } + + return res.status(200).send({ + secrets + }); +} + +/** + * Update secret(s) + * @param req + * @param res + */ +export const updateSecrets = async (req: Request, res: Response) => { + const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; + + // TODO: move type + interface PatchSecret { + id: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext: string; + secretCommentIV: string; + secretCommentTag: string; + } + + const ops = req.body.secrets.map((secret: PatchSecret) => { + const { + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } = secret; + return ({ + updateOne: { + filter: { _id: new Types.ObjectId(secret.id) }, + update: { + $inc: { + version: 1 + }, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + ...(( + secretCommentCiphertext && + secretCommentIV && + secretCommentTag + ) ? { + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } : {}), + } + } + }); + }); + await Secret.bulkWrite(ops); + + const newSecretsObj: { [key: string]: PatchSecret } = {}; + req.body.secrets.forEach((secret: PatchSecret) => { + newSecretsObj[secret.id] = secret; + }); + + await EESecretService.addSecretVersions({ + secretVersions: req.secrets.map((secret: ISecret) => { + const { + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } = newSecretsObj[secret._id.toString()] + return ({ + secret: secret._id, + version: secret.version + 1, + workspace: secret.workspace, + type: secret.type, + environment: secret.environment, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + ...(( + secretCommentCiphertext && + secretCommentIV && + secretCommentTag + ) ? { + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } : { + secretCommentCiphertext: '', + secretCommentIV: '', + secretCommentTag: '' + }) + }); + }) + }); + + // group secrets into workspaces so updated secrets can + // be logged and snapshotted separately for each workspace + const workspaceSecretObj: any = {}; + req.secrets.forEach((s: any) => { + if (s.workspace.toString() in workspaceSecretObj) { + workspaceSecretObj[s.workspace.toString()].push(s); + } else { + workspaceSecretObj[s.workspace.toString()] = [s] + } + }); + + Object.keys(workspaceSecretObj).forEach(async (key) => { + const updateAction = await EELogService.createActionSecret({ + name: ACTION_UPDATE_SECRETS, + userId: req.user._id.toString(), + workspaceId: key, + secretIds: workspaceSecretObj[key].map((secret: ISecret) => secret._id) + }); + + // (EE) create (audit) log + updateAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: key, + actions: [updateAction], + channel, + ipAddress: req.ip + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId: key + }) + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets modified', + distinctId: req.user.email, + properties: { + numberOfSecrets: workspaceSecretObj[key].length, + environment: workspaceSecretObj[key][0].environment, + workspaceId: key, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + }); + + return res.status(200).send({ + secrets: await Secret.find({ + _id: { + $in: req.secrets.map((secret: ISecret) => secret._id) + } + }) + }); +} + +/** + * Delete secret(s) with id [workspaceId] and environment [environment] + * @param req + * @param res + */ +export const deleteSecrets = async (req: Request, res: Response) => { + const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli'; + const toDelete = req.secrets.map((s: any) => s._id); + + await Secret.deleteMany({ + _id: { + $in: toDelete + } + }); + + await EESecretService.markDeletedSecretVersions({ + secretIds: toDelete + }); + + // group secrets into workspaces so deleted secrets can + // be logged and snapshotted separately for each workspace + const workspaceSecretObj: any = {}; + req.secrets.forEach((s: any) => { + if (s.workspace.toString() in workspaceSecretObj) { + workspaceSecretObj[s.workspace.toString()].push(s); + } else { + workspaceSecretObj[s.workspace.toString()] = [s] + } + }); + + Object.keys(workspaceSecretObj).forEach(async (key) => { + const deleteAction = await EELogService.createActionSecret({ + name: ACTION_DELETE_SECRETS, + userId: req.user._id.toString(), + workspaceId: key, + secretIds: workspaceSecretObj[key].map((secret: ISecret) => secret._id) + }); + + // (EE) create (audit) log + deleteAction && await EELogService.createLog({ + userId: req.user._id.toString(), + workspaceId: key, + actions: [deleteAction], + channel, + ipAddress: req.ip + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId: key + }) + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: req.user.email, + properties: { + numberOfSecrets: workspaceSecretObj[key].length, + environment: workspaceSecretObj[key][0].environment, + workspaceId: key, + channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli', + userAgent: req.headers?.['user-agent'] + } + }); + } + }); + + return res.status(200).send({ + secrets: req.secrets + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index a2d68ca96..751f21611 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -1,6 +1,8 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; +import { Secret } from '../../../models'; import { SecretVersion } from '../../models'; +import { EESecretService } from '../../services'; /** * Return secret versions for secret with id [secretId] @@ -33,4 +35,103 @@ import { SecretVersion } from '../../models'; return res.status(200).send({ secretVersions }); +} + +/** + * Roll back secret with id [secretId] to version [version] + * @param req + * @param res + * @returns + */ +export const rollbackSecretVersion = async (req: Request, res: Response) => { + let secret; + try { + const { secretId } = req.params; + const { version } = req.body; + + // validate secret version + const oldSecretVersion = await SecretVersion.findOne({ + secret: secretId, + version + }); + + if (!oldSecretVersion) throw new Error('Failed to find secret version'); + + const { + workspace, + type, + user, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + } = oldSecretVersion; + + // update secret + secret = await Secret.findByIdAndUpdate( + secretId, + { + $inc: { + version: 1 + }, + workspace, + type, + user, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }, + { + new: true + } + ); + + if (!secret) throw new Error('Failed to find and update secret'); + + // add new secret version + await new SecretVersion({ + secret: secretId, + version: secret.version, + workspace, + type, + user, + environment, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }).save(); + + // take secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId: secret.workspace.toString() + }); + + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to roll back secret version' + }); + } + + return res.status(200).send({ + secret + }); } \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/secretSnapshotController.ts b/backend/src/ee/controllers/v1/secretSnapshotController.ts index 40e1a74a6..6e8605c2f 100644 --- a/backend/src/ee/controllers/v1/secretSnapshotController.ts +++ b/backend/src/ee/controllers/v1/secretSnapshotController.ts @@ -2,6 +2,12 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; import { SecretSnapshot } from '../../models'; +/** + * Return secret snapshot with id [secretSnapshotId] + * @param req + * @param res + * @returns + */ export const getSecretSnapshot = async (req: Request, res: Response) => { let secretSnapshot; try { diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 88c31b8e1..8fd7c8746 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -1,9 +1,17 @@ -import e, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; +import { + Secret +} from '../../../models'; import { SecretSnapshot, - Log + Log, + SecretVersion, + ISecretVersion } from '../../models'; +import { EESecretService } from '../../services'; +import { getLatestSecretVersionIds } from '../../helpers/secretVersion'; /** * Return secret snapshots for workspace with id [workspaceId] @@ -63,6 +71,159 @@ export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Respon }); } +/** + * Rollback secret snapshot with id [secretSnapshotId] to version [version] + * @param req + * @param res + * @returns + */ +export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Response) => { + let secrets; + try { + const { workspaceId } = req.params; + const { version } = req.body; + + // validate secret snapshot + const secretSnapshot = await SecretSnapshot.findOne({ + workspace: workspaceId, + version + }).populate<{ secretVersions: ISecretVersion[]}>('secretVersions'); + + if (!secretSnapshot) throw new Error('Failed to find secret snapshot'); + + // TODO: fix any + const oldSecretVersionsObj: any = secretSnapshot.secretVersions + .reduce((accumulator, s) => ({ + ...accumulator, + [`${s.secret.toString()}`]: s + }), {}); + + const latestSecretVersionIds = await getLatestSecretVersionIds({ + secretIds: secretSnapshot.secretVersions.map((sv) => sv.secret) + }); + + // TODO: fix any + const latestSecretVersions: any = (await SecretVersion.find({ + _id: { + $in: latestSecretVersionIds.map((s) => s.versionId) + } + }, 'secret version')) + .reduce((accumulator, s) => ({ + ...accumulator, + [`${s.secret.toString()}`]: s + }), {}); + + // delete existing secrets + await Secret.deleteMany({ + workspace: workspaceId + }); + + // add secrets + secrets = await Secret.insertMany( + secretSnapshot.secretVersions.map((sv) => { + const secretId = sv.secret; + const { + workspace, + type, + user, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + createdAt + } = oldSecretVersionsObj[secretId.toString()]; + + return ({ + _id: secretId, + version: latestSecretVersions[secretId.toString()].version + 1, + workspace, + type, + user, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash, + secretCommentCiphertext: '', + secretCommentIV: '', + secretCommentTag: '', + createdAt + }); + }) + ); + + // add secret versions + await SecretVersion.insertMany( + secrets.map(({ + _id, + version, + workspace, + type, + user, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + }) => ({ + _id: new Types.ObjectId(), + secret: _id, + version, + workspace, + type, + user, + environment, + isDeleted: false, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretKeyHash, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretValueHash + })) + ); + + // update secret versions of restored secrets as not deleted + await SecretVersion.updateMany({ + secret: { + $in: secretSnapshot.secretVersions.map((sv) => sv.secret) + } + }, { + isDeleted: false + }); + + // take secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + return res.status(400).send({ + message: 'Failed to roll back secret snapshot' + }); + } + + return res.status(200).send({ + secrets + }); +} + /** * Return (audit) logs for workspace with id [workspaceId] * @param req diff --git a/backend/src/ee/helpers/action.ts b/backend/src/ee/helpers/action.ts index 2971e3f96..389cfedbf 100644 --- a/backend/src/ee/helpers/action.ts +++ b/backend/src/ee/helpers/action.ts @@ -1,7 +1,10 @@ import * as Sentry from '@sentry/node'; import { Types } from 'mongoose'; -import { Secret } from '../../models'; import { SecretVersion, Action } from '../models'; +import { + getLatestSecretVersionIds, + getLatestNSecretSecretVersionIds +} from '../helpers/secretVersion'; import { ACTION_UPDATE_SECRETS } from '../../variables'; /** @@ -30,65 +33,23 @@ const createActionSecretHelper = async ({ if (name === ACTION_UPDATE_SECRETS) { // case: action is updating secrets // -> add old and new secret versions - - // TODO: make query more efficient - latestSecretVersions = (await SecretVersion.aggregate([ - { - $match: { - secret: { - $in: secretIds, - }, - }, - }, - { - $sort: { version: -1 }, - }, - { - $group: { - _id: "$secret", - versions: { $push: "$$ROOT" }, - }, - }, - { - $project: { - _id: 0, - secret: "$_id", - versions: { $slice: ["$versions", 2] }, - }, - } - ])) - .map((s) => ({ - oldSecretVersion: s.versions[0]._id, - newSecretVersion: s.versions[1]._id - })); - - + latestSecretVersions = (await getLatestNSecretSecretVersionIds({ + secretIds, + n: 2 + })) + .map((s) => ({ + oldSecretVersion: s.versions[0]._id, + newSecretVersion: s.versions[1]._id + })); } else { // case: action is adding, deleting, or reading secrets // -> add new secret versions - latestSecretVersions = (await SecretVersion.aggregate([ - { - $match: { - secret: { - $in: secretIds - } - } - }, - { - $group: { - _id: '$secret', - version: { $max: '$version' }, - versionId: { $max: '$_id' } // secret version id - } - }, - { - $sort: { version: -1 } - } - ]) - .exec()) - .map((s) => ({ - newSecretVersion: s.versionId - })); + latestSecretVersions = (await getLatestSecretVersionIds({ + secretIds + })) + .map((s) => ({ + newSecretVersion: s.versionId + })); } action = await new Action({ diff --git a/backend/src/ee/helpers/secretVersion.ts b/backend/src/ee/helpers/secretVersion.ts new file mode 100644 index 000000000..d5859e183 --- /dev/null +++ b/backend/src/ee/helpers/secretVersion.ts @@ -0,0 +1,110 @@ +import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; +import { SecretVersion } from '../models'; + +/** + * Return latest secret versions for secrets with ids [secretIds] + * @param {Object} obj + * @param {Object} obj.secretIds = ids of secrets to get latest versions for + * @returns + */ +const getLatestSecretVersionIds = async ({ + secretIds +}: { + secretIds: Types.ObjectId[]; +}) => { + + interface LatestSecretVersionId { + _id: Types.ObjectId; + version: number; + versionId: Types.ObjectId; + } + + let latestSecretVersionIds: LatestSecretVersionId[]; + try { + latestSecretVersionIds = (await SecretVersion.aggregate([ + { + $match: { + secret: { + $in: secretIds + } + } + }, + { + $group: { + _id: '$secret', + version: { $max: '$version' }, + versionId: { $max: '$_id' } // id of latest secret version + } + }, + { + $sort: { version: -1 } + } + ]) + .exec()); + + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get latest secret versions'); + } + + return latestSecretVersionIds; +} + +/** + * Return latest [n] secret versions for secrets with ids [secretIds] + * @param {Object} obj + * @param {Object} obj.secretIds = ids of secrets to get latest versions for + * @param {Number} obj.n - number of latest secret versions to return for each secret + * @returns + */ +const getLatestNSecretSecretVersionIds = async ({ + secretIds, + n +}: { + secretIds: Types.ObjectId[]; + n: number; +}) => { + + // TODO: optimize query + let latestNSecretVersions; + try { + latestNSecretVersions = (await SecretVersion.aggregate([ + { + $match: { + secret: { + $in: secretIds, + }, + }, + }, + { + $sort: { version: -1 }, + }, + { + $group: { + _id: "$secret", + versions: { $push: "$$ROOT" }, + }, + }, + { + $project: { + _id: 0, + secret: "$_id", + versions: { $slice: ["$versions", n] }, + }, + } + ])); + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error('Failed to get latest n secret versions'); + } + + return latestNSecretVersions; +} + +export { + getLatestSecretVersionIds, + getLatestNSecretSecretVersionIds +} diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 0197c3a25..616d44fbd 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -8,17 +8,8 @@ import { ENV_PROD } from '../../variables'; -/** - * TODO: - * 1. Modify SecretVersion to also contain XX - * - type - * - user - * - environment - * 2. Modify SecretSnapshot to point to arrays of SecretVersion - */ - export interface ISecretVersion { - _id?: Types.ObjectId; + _id: Types.ObjectId; secret: Types.ObjectId; version: number; workspace: Types.ObjectId; // new @@ -68,7 +59,7 @@ const secretVersionSchema = new Schema( enum: [ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD], required: true }, - isDeleted: { + isDeleted: { // consider removing field type: Boolean, default: false, required: true @@ -86,8 +77,7 @@ const secretVersionSchema = new Schema( required: true }, secretKeyHash: { - type: String, - required: true + type: String }, secretValueCiphertext: { type: String, @@ -102,8 +92,7 @@ const secretVersionSchema = new Schema( required: true }, secretValueHash: { - type: String, - required: true + type: String } }, { diff --git a/backend/src/ee/routes/v1/secret.ts b/backend/src/ee/routes/v1/secret.ts index 43cc8bafc..ac3089e33 100644 --- a/backend/src/ee/routes/v1/secret.ts +++ b/backend/src/ee/routes/v1/secret.ts @@ -5,7 +5,7 @@ import { requireSecretAuth, validateRequest } from '../../../middleware'; -import { query, param } from 'express-validator'; +import { query, param, body } from 'express-validator'; import { secretController } from '../../controllers/v1'; import { ADMIN, MEMBER } from '../../../variables'; @@ -24,4 +24,17 @@ router.get( secretController.getSecretVersions ); +router.post( + '/:secretId/secret-versions/rollback', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireSecretAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + param('secretId').exists().trim(), + body('version').exists().isInt(), + secretController.rollbackSecretVersion +); + export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/secretSnapshot.ts b/backend/src/ee/routes/v1/secretSnapshot.ts index 80aa7d1ee..d10da4456 100644 --- a/backend/src/ee/routes/v1/secretSnapshot.ts +++ b/backend/src/ee/routes/v1/secretSnapshot.ts @@ -7,7 +7,7 @@ import { requireAuth, validateRequest } from '../../../middleware'; -import { param } from 'express-validator'; +import { param, body } from 'express-validator'; import { ADMIN, MEMBER } from '../../../variables'; import { secretSnapshotController } from '../../controllers/v1'; diff --git a/backend/src/ee/routes/v1/workspace.ts b/backend/src/ee/routes/v1/workspace.ts index 4b2e839eb..c9da58261 100644 --- a/backend/src/ee/routes/v1/workspace.ts +++ b/backend/src/ee/routes/v1/workspace.ts @@ -5,7 +5,7 @@ import { requireWorkspaceAuth, validateRequest } from '../../../middleware'; -import { param, query } from 'express-validator'; +import { param, query, body } from 'express-validator'; import { ADMIN, MEMBER } from '../../../variables'; import { workspaceController } from '../../controllers/v1'; @@ -37,6 +37,20 @@ router.get( workspaceController.getWorkspaceSecretSnapshotsCount ); +router.post( + '/:workspaceId/secret-snapshots/rollback', + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + param('workspaceId').exists().trim(), + body('version').exists().isInt(), + validateRequest, + workspaceController.rollbackWorkspaceSecretSnapshot +); + router.get( '/:workspaceId/logs', requireAuth({ diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index b3f276b53..7519ef18b 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -72,7 +72,7 @@ const getSecretsHelper = async ({ try { const key = await getKey({ workspaceId }); const secrets = await Secret.find({ - workspaceId, + workspace: workspaceId, environment, type: SECRET_SHARED }); @@ -84,7 +84,7 @@ const getSecretsHelper = async ({ tag: secret.secretKeyTag, key }); - + const secretValue = decryptSymmetric({ ciphertext: secret.secretValueCiphertext, iv: secret.secretValueIV, diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 920e8dc1d..59d72f469 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -3,6 +3,7 @@ import { Types } from 'mongoose'; import { Secret, ISecret, + Membership } from '../models'; import { EESecretService, @@ -20,6 +21,46 @@ import { ACTION_READ_SECRETS } from '../variables'; +/** + * Validate that user with id [userId] can modify secrets with ids [secretIds] + * @param {Object} obj + * @param {Object} obj.userId - id of user to validate + * @param {Object} obj.secretIds - secret ids + * @returns {Secret[]} secrets + */ +const validateSecrets = async ({ + userId, + secretIds +}: { + userId: string; + secretIds: string[]; +}) =>{ + let secrets; + try { + secrets = await Secret.find({ + _id: { + $in: secretIds + } + }); + + const workspaceIdsSet = new Set((await Membership.find({ + user: userId + }, 'workspace')) + .map((m) => m.workspace.toString())); + + secrets.forEach((secret: ISecret) => { + if (!workspaceIdsSet.has(secret.workspace.toString())) { + throw new Error('Failed to validate secret'); + } + }); + + } catch (err) { + throw new Error('Failed to validate secrets'); + } + + return secrets; +} + interface V1PushSecret { ciphertextKey: string; ivKey: string; @@ -187,6 +228,7 @@ const v1PushSecrets = async ({ }) => { const newSecret = newSecretsObj[`${type}-${secretKeyHash}`]; return ({ + _id: new Types.ObjectId(), secret: _id, version: version ? version + 1 : 1, workspace: new Types.ObjectId(workspaceId), @@ -258,6 +300,7 @@ const v1PushSecrets = async ({ secretValueTag, secretValueHash }) => ({ + _id: new Types.ObjectId(), secret: _id, version, workspace, @@ -280,7 +323,7 @@ const v1PushSecrets = async ({ // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ workspaceId - }) + }); } catch (err) { Sentry.setUser(null); Sentry.captureException(err); @@ -527,6 +570,7 @@ const v1PushSecrets = async ({ environment: string; }): Promise => { let secrets: any; // TODO: FIX any + try { // get shared workspace secrets const sharedSecrets = await Secret.find({ @@ -655,6 +699,7 @@ const reformatPullSecrets = ({ secrets }: { secrets: ISecret[] }) => { }; export { + validateSecrets, v1PushSecrets, v2PushSecrets, pullSecrets, diff --git a/backend/src/middleware/index.ts b/backend/src/middleware/index.ts index 119a86abe..6a3537076 100644 --- a/backend/src/middleware/index.ts +++ b/backend/src/middleware/index.ts @@ -8,6 +8,7 @@ import requireIntegrationAuthorizationAuth from './requireIntegrationAuthorizati import requireServiceTokenAuth from './requireServiceTokenAuth'; import requireServiceTokenDataAuth from './requireServiceTokenDataAuth'; import requireSecretAuth from './requireSecretAuth'; +import requireSecretsAuth from './requireSecretsAuth'; import validateRequest from './validateRequest'; export { @@ -21,5 +22,6 @@ export { requireServiceTokenAuth, requireServiceTokenDataAuth, requireSecretAuth, + requireSecretsAuth, validateRequest }; diff --git a/backend/src/middleware/requireSecretAuth.ts b/backend/src/middleware/requireSecretAuth.ts index c6a291200..36e47247e 100644 --- a/backend/src/middleware/requireSecretAuth.ts +++ b/backend/src/middleware/requireSecretAuth.ts @@ -5,6 +5,9 @@ import { validateMembership } from '../helpers/membership'; +// note: used for old /v1/secret and /v2/secret routes. +// newer /v2/secrets routes use [requireSecretsAuth] middleware + /** * Validate if user on request has proper membership to modify secret. * @param {Object} obj @@ -34,7 +37,7 @@ const requireSecretAuth = ({ acceptedRoles }); - req.secret = secret as any; + req._secret = secret; next(); } catch (err) { diff --git a/backend/src/middleware/requireSecretsAuth.ts b/backend/src/middleware/requireSecretsAuth.ts new file mode 100644 index 000000000..c8b89a74c --- /dev/null +++ b/backend/src/middleware/requireSecretsAuth.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction } from 'express'; +import { UnauthorizedRequestError } from '../utils/errors'; +import { Secret, Membership } from '../models'; +import { validateSecrets } from '../helpers/secret'; + +// TODO: make this work for delete route + +const requireSecretsAuth = ({ + acceptedRoles +}: { + acceptedRoles: string[]; +}) => { + return async (req: Request, res: Response, next: NextFunction) => { + let secrets; + try { + if (Array.isArray(req.body.secrets)) { + // case: validate multiple secrets + secrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds: req.body.secrets.map((s: any) => s.id) + }); + } else if (typeof req.body.secrets === 'object') { // change this to check for object + // case: validate 1 secret + secrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds: req.body.secrets.id + }); + } else if (Array.isArray(req.body.secretIds)) { + secrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds: req.body.secretIds + }); + } else if (typeof req.body.secretIds === 'string') { + // case: validate secretIds + secrets = await validateSecrets({ + userId: req.user._id.toString(), + secretIds: [req.body.secretIds] + }); + } + + req.secrets = secrets; + return next(); + } catch (err) { + return next(UnauthorizedRequestError({ message: 'Unable to authenticate secret(s)' })); + } + } +} + +export default requireSecretsAuth; \ No newline at end of file diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index bbaaff8c3..a01b92d80 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -69,8 +69,7 @@ const secretSchema = new Schema( required: true }, secretKeyHash: { - type: String, - required: true + type: String }, secretValueCiphertext: { type: String, @@ -85,8 +84,7 @@ const secretSchema = new Schema( required: true }, secretValueHash: { - type: String, - required: true + type: String }, secretCommentCiphertext: { type: String, diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index d0f3833ba..8bea42620 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -1,10 +1,12 @@ -import secret from './secret'; +import secret from './secret'; // stop-supporting +import secrets from './secrets'; import workspace from './workspace'; import serviceTokenData from './serviceTokenData'; import apiKeyData from './apiKeyData'; export { secret, + secrets, workspace, serviceTokenData, apiKeyData diff --git a/backend/src/routes/v2/secret.ts b/backend/src/routes/v2/secret.ts index 893af8683..b0c0f8f36 100644 --- a/backend/src/routes/v2/secret.ts +++ b/backend/src/routes/v2/secret.ts @@ -1,18 +1,21 @@ -import express, { Request, Response } from 'express'; -import { requireAuth, requireWorkspaceAuth, validateRequest } from '../../middleware'; +import express from 'express'; +import { + requireAuth, + requireWorkspaceAuth, + requireSecretAuth, + validateRequest +} from '../../middleware'; import { body, param, query } from 'express-validator'; import { ADMIN, MEMBER } from '../../variables'; import { CreateSecretRequestBody, ModifySecretRequestBody } from '../../types/secret'; import { secretController } from '../../controllers/v2'; -import { fetchAllSecrets, fetchSingleSecret } from '../../controllers/v2/secretController'; + +// note to devs: stop supporting const router = express.Router(); -/** - * Create many secrets for a given workspace and environmentName - */ router.post( - '/batch-create/workspace/:workspaceId/environment/:environmentName', + '/batch-create/workspace/:workspaceId/environment/:environment', requireAuth({ acceptedAuthModes: ['jwt'] }), @@ -20,17 +23,15 @@ router.post( acceptedRoles: [ADMIN, MEMBER] }), param('workspaceId').exists().isMongoId().trim(), - param('environmentName').exists().trim(), + param('environment').exists().trim(), body('secrets').exists().isArray().custom((value) => value.every((item: CreateSecretRequestBody) => typeof item === 'object')), + body('channel'), validateRequest, - secretController.batchCreateSecrets + secretController.createSecrets ); -/** - * Create single secret for a given workspace and environmentName - */ router.post( - '/workspace/:workspaceId/environment/:environmentName', + '/workspace/:workspaceId/environment/:environment', requireAuth({ acceptedAuthModes: ['jwt'] }), @@ -38,15 +39,13 @@ router.post( acceptedRoles: [ADMIN, MEMBER] }), param('workspaceId').exists().isMongoId().trim(), - param('environmentName').exists().trim(), + param('environment').exists().trim(), body('secret').exists().isObject(), + body('channel'), validateRequest, - secretController.createSingleSecret + secretController.createSecret ); -/** - * Get all secrets for a given environment and workspace id - */ router.get( '/workspace/:workspaceId', param('workspaceId').exists().trim(), @@ -57,25 +56,23 @@ router.get( requireWorkspaceAuth({ acceptedRoles: [ADMIN, MEMBER] }), + query('channel'), validateRequest, - fetchAllSecrets + secretController.getSecrets ); -/** - * Get single secret by id - */ router.get( '/:secretId', requireAuth({ acceptedAuthModes: ['jwt', 'serviceToken'] }), + requireSecretAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), validateRequest, - fetchSingleSecret + secretController.getSecret ); -/** - * Batch delete secrets in a given workspace and environment name - */ router.delete( '/batch/workspace/:workspaceId/environment/:environmentName', requireAuth({ @@ -88,26 +85,22 @@ router.delete( acceptedRoles: [ADMIN, MEMBER] }), validateRequest, - secretController.batchDeleteSecrets - + secretController.deleteSecrets ); -/** - * delete single secret by id - */ router.delete( '/:secretId', requireAuth({ acceptedAuthModes: ['jwt'] }), + requireSecretAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), param('secretId').isMongoId(), validateRequest, - secretController.deleteSingleSecret + secretController.deleteSecret ); -/** - * Apply modifications to many existing secrets in a given workspace and environment - */ router.patch( '/batch-modify/workspace/:workspaceId/environment/:environmentName', requireAuth({ @@ -120,12 +113,10 @@ router.patch( acceptedRoles: [ADMIN, MEMBER] }), validateRequest, - secretController.batchModifySecrets + secretController.updateSecrets ); -/** - * Apply modifications to single existing secret in a given workspace and environment - */ + router.patch( '/workspace/:workspaceId/environment/:environmentName', requireAuth({ @@ -138,7 +129,7 @@ router.patch( acceptedRoles: [ADMIN, MEMBER] }), validateRequest, - secretController.modifySingleSecrets + secretController.updateSecret ); export default router; diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts new file mode 100644 index 000000000..29eac042f --- /dev/null +++ b/backend/src/routes/v2/secrets.ts @@ -0,0 +1,169 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + requireWorkspaceAuth, + requireSecretsAuth, + validateRequest +} from '../../middleware'; +import { query, check, body } from 'express-validator'; +import { secretsController } from '../../controllers/v2'; +import { + ADMIN, + MEMBER, + SECRET_PERSONAL, + SECRET_SHARED +} from '../../variables'; + +router.post( + '/', + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim().isIn(['dev', 'staging', 'prod', 'test']), + body('secrets') + .exists() + .custom((value) => { + if (Array.isArray(value)) { + // case: create multiple secrets + if (value.length === 0) throw new Error('secrets cannot be an empty array') + for (const secret of value) { + if ( + !secret.type || + !(secret.type === SECRET_PERSONAL || secret.type === SECRET_SHARED) || + !secret.secretKeyCiphertext || + !secret.secretKeyIV || + !secret.secretKeyTag || + !secret.secretValueCiphertext || + !secret.secretValueIV || + !secret.secretValueTag + ) { + throw new Error('secrets array must contain objects that have required secret properties'); + } + } + } else if (typeof value === 'object') { + // case: update 1 secret + if ( + !value.type || + !(value.type === SECRET_PERSONAL || value.type === SECRET_SHARED) || + !value.secretKeyCiphertext || + !value.secretKeyIV || + !value.secretKeyTag || + !value.secretValueCiphertext || + !value.secretValueIV || + !value.secretValueTag + ) { + throw new Error('secrets object is missing required secret properties'); + } + } else { + throw new Error('secrets must be an object or an array of objects') + } + + return true; + }), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'body' + }), + secretsController.createSecrets +); + +router.get( + '/', + query('workspaceId').exists().trim(), + query('environment').exists().trim().isIn(['dev', 'staging', 'prod', 'test']), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt', 'serviceToken'] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + location: 'query' + }), + secretsController.getSecrets +); + +router.patch( + '/', + body('secrets') + .exists() + .custom((value) => { + if (Array.isArray(value)) { + // case: update multiple secrets + if (value.length === 0) throw new Error('secrets cannot be an empty array') + for (const secret of value) { + if ( + !secret.id || + !secret.secretKeyCiphertext || + !secret.secretKeyIV || + !secret.secretKeyTag || + !secret.secretValueCiphertext || + !secret.secretValueIV || + !secret.secretValueTag + ) { + throw new Error('secrets array must contain objects that have required secret properties'); + } + } + } else if (typeof value === 'object') { + // case: update 1 secret + if ( + !value.id || + !value.secretKeyCiphertext || + !value.secretKeyIV || + !value.secretKeyTag || + !value.secretValueCiphertext || + !value.secretValueIV || + !value.secretValueTag + ) { + throw new Error('secrets object is missing required secret properties'); + } + } else { + throw new Error('secrets must be an object or an array of objects') + } + + return true; + }), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireSecretsAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + secretsController.updateSecrets +); + +router.delete( + '/', + body('secretIds') + .exists() + .custom((value) => { + // case: delete 1 secret + if (typeof value === 'string') return true; + + if (Array.isArray(value)) { + // case: delete multiple secrets + if (value.length === 0) throw new Error('secrets cannot be an empty array'); + return value.every((id: string) => typeof id === 'string') + } + + throw new Error('secretIds must be a string or an array of strings'); + }) + .not() + .isEmpty(), + validateRequest, + requireAuth({ + acceptedAuthModes: ['jwt'] + }), + requireSecretsAuth({ + acceptedRoles: [ADMIN, MEMBER] + }), + secretsController.deleteSecrets +); + +export default router; + + + diff --git a/backend/src/services/PostHogClient.ts b/backend/src/services/PostHogClient.ts index 4ce0117f0..0d91a1c13 100644 --- a/backend/src/services/PostHogClient.ts +++ b/backend/src/services/PostHogClient.ts @@ -7,12 +7,12 @@ import { } from '../config'; import { getLogger } from '../utils/logger'; -if(TELEMETRY_ENABLED){ +if(!TELEMETRY_ENABLED){ getLogger("backend-main").info([ "", - "Infisical collects telemetry data about general usage.", - "The data helps us understand how the product is doing and guide our product development to create the best possible platform; it also helps us demonstrate growth for investors as we support Infisical as open-source software.", - "To opt out of telemetry, you can set `TELEMETRY_ENABLED=false` within the environment variables", + "To improve, Infisical collects telemetry data about general usage.", + "This helps us understand how the product is doing and guide our product development to create the best possible platform; it also helps us demonstrate growth as we support Infisical as open-source software.", + "To opt into telemetry, you can set `TELEMETRY_ENABLED=true` within the environment variables.", ].join('\n')) } diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index f43b5fa79..ae9edb4c5 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -1,4 +1,5 @@ import * as express from 'express'; +import { ISecret } from '../../models'; // TODO: fix (any) types declare global { @@ -12,7 +13,8 @@ declare global { integration: any; integrationAuth: any; bot: any; - secret: any; + _secret: any; + secrets: any; secretSnapshot: any; serviceToken: any; accessToken: any; diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index ed18c5a2a..00e817c57 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -48,7 +48,7 @@ const INTEGRATION_OPTIONS = [ name: 'Vercel', slug: 'vercel', image: 'Vercel', - isAvailable: false, + isAvailable: true, type: 'vercel', clientId: '', clientSlug: CLIENT_SLUG_VERCEL, @@ -58,7 +58,7 @@ const INTEGRATION_OPTIONS = [ name: 'Netlify', slug: 'netlify', image: 'Netlify', - isAvailable: false, + isAvailable: true, type: 'oauth2', clientId: CLIENT_ID_NETLIFY, docsLink: '' @@ -67,7 +67,7 @@ const INTEGRATION_OPTIONS = [ name: 'GitHub', slug: 'github', image: 'GitHub', - isAvailable: false, + isAvailable: true, type: 'oauth2', clientId: CLIENT_ID_GITHUB, docsLink: '' diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 96d8817b5..fd04e9ce2 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -31,8 +31,8 @@ var exportCmd = &cobra.Command{ Args: cobra.NoArgs, PreRun: func(cmd *cobra.Command, args []string) { toggleDebug(cmd, args) - util.RequireLogin() - util.RequireLocalWorkspaceFile() + // util.RequireLogin() + // util.RequireLocalWorkspaceFile() }, Run: func(cmd *cobra.Command, args []string) { envName, err := cmd.Flags().GetString("env") diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index 923782e3f..c10f38fc4 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -6,6 +6,7 @@ package cmd import ( "encoding/base64" "encoding/hex" + "strings" "errors" "fmt" @@ -17,6 +18,7 @@ import ( "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/srp" "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" "github.com/go-resty/resty/v2" "github.com/manifoldco/promptui" log "github.com/sirupsen/logrus" @@ -31,7 +33,9 @@ var loginCmd = &cobra.Command{ PreRun: toggleDebug, Run: func(cmd *cobra.Command, args []string) { currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() - if err != nil { + if err != nil && strings.Contains(err.Error(), "The specified item could not be found in the keyring") { // if the key can't be found allow them to override + log.Debug(err) + } else if err != nil { util.HandleError(err) } @@ -97,7 +101,7 @@ var loginCmd = &cobra.Command{ util.HandleError(err, "Unable to write write to Infisical Config file. Please try again") } - log.Infoln("Nice! You are loggin as:", email) + color.Green("Nice! You are logged in as: %v", email) }, } diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 87b4cda4e..fe07dee82 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -12,8 +12,8 @@ import ( "strings" "syscall" - "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -85,16 +85,50 @@ var runCmd = &cobra.Command{ secrets = util.OverrideWithPersonalSecrets(secrets) } + secretsByKey := getSecretsByKeys(secrets) + environmentVariables := make(map[string]string) + + // add all existing environment vars + for _, s := range os.Environ() { + kv := strings.SplitN(s, "=", 2) + key := kv[0] + value := kv[1] + environmentVariables[key] = value + } + + // check to see if there are any reserved key words in secrets to inject + reservedEnvironmentVariables := []string{"HOME", "PATH", "PS1", "PS2"} + for _, reservedEnvName := range reservedEnvironmentVariables { + if _, ok := secretsByKey[reservedEnvName]; ok { + delete(secretsByKey, reservedEnvName) + util.PrintWarning(fmt.Sprintf("Infisical secret named [%v] has been removed because it is a reserved secret name", reservedEnvName)) + } + } + + // now add infisical secrets + for k, v := range secretsByKey { + environmentVariables[k] = v.Value + } + + // turn it back into a list of envs + var env []string + for key, value := range environmentVariables { + s := key + "=" + value + env = append(env, s) + } + + log.Debugf("injecting the following environment variables into shell: %v", env) + if cmd.Flags().Changed("command") { command := cmd.Flag("command").Value.String() - err = executeMultipleCommandWithEnvs(command, secrets) + err = executeMultipleCommandWithEnvs(command, len(secretsByKey), env) if err != nil { util.HandleError(err, "Unable to execute your chained command") } } else { - err = executeSingleCommandWithEnvs(args, secrets) + err = executeSingleCommandWithEnvs(args, len(secretsByKey), env) if err != nil { util.HandleError(err, "Unable to execute your single command") } @@ -111,24 +145,21 @@ func init() { } // Will execute a single command and pass in the given secrets into the process -func executeSingleCommandWithEnvs(args []string, secrets []models.SingleEnvironmentVariable) error { +func executeSingleCommandWithEnvs(args []string, secretsCount int, env []string) error { command := args[0] argsForCommand := args[1:] - numberOfSecretsInjected := fmt.Sprintf("\u2713 Injected %v Infisical secrets into your application process successfully", len(secrets)) - log.Infof("\x1b[%dm%s\x1b[0m", 32, numberOfSecretsInjected) - log.Debugf("executing command: %s %s \n", command, strings.Join(argsForCommand, " ")) - log.Debugf("Secrets injected: %v", secrets) + color.Green("Injecting %v Infisical secrets into your application process", secretsCount) cmd := exec.Command(command, argsForCommand...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = getAllEnvs(secrets) + cmd.Env = env return execCmd(cmd) } -func executeMultipleCommandWithEnvs(fullCommand string, secrets []models.SingleEnvironmentVariable) error { +func executeMultipleCommandWithEnvs(fullCommand string, secretsCount int, env []string) error { shell := [2]string{"sh", "-c"} if runtime.GOOS == "windows" { shell = [2]string{"cmd", "/C"} @@ -140,12 +171,10 @@ func executeMultipleCommandWithEnvs(fullCommand string, secrets []models.SingleE cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr - cmd.Env = getAllEnvs(secrets) + cmd.Env = env - numberOfSecretsInjected := fmt.Sprintf("\u2713 Injected %v Infisical secrets into your application process successfully", len(secrets)) - log.Infof("\x1b[%dm%s\x1b[0m", 32, numberOfSecretsInjected) + color.Green("Injecting %v Infisical secrets into your application process", secretsCount) log.Debugf("executing command: %s %s %s \n", shell[0], shell[1], fullCommand) - log.Debugf("Secrets injected: %v", secrets) return execCmd(cmd) } @@ -175,23 +204,3 @@ func execCmd(cmd *exec.Cmd) error { os.Exit(waitStatus.ExitStatus()) return nil } - -func getAllEnvs(envsToInject []models.SingleEnvironmentVariable) []string { - env_map := make(map[string]string) - - for _, env := range os.Environ() { - splitEnv := strings.Split(env, "=") - env_map[splitEnv[0]] = splitEnv[1] - } - - for _, env := range envsToInject { - env_map[env.Key] = env.Value // overrite any envs with ones to inject if they clash - } - - var allEnvs []string - for key, value := range env_map { - allEnvs = append(allEnvs, fmt.Sprintf("%s=%s", key, value)) - } - - return allEnvs -} diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index df77e67d4..5a9ce2724 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -311,14 +311,25 @@ var secretsDeleteCmd = &cobra.Command{ func init() { secretsCmd.AddCommand(secretsGetCmd) - secretsCmd.AddCommand(secretsSetCmd) - secretsCmd.AddCommand(secretsDeleteCmd) - secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") - secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") - secretsCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + secretsGetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { util.RequireLogin() util.RequireLocalWorkspaceFile() } + + secretsCmd.AddCommand(secretsSetCmd) + secretsSetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + } + + secretsCmd.AddCommand(secretsDeleteCmd) + secretsDeleteCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + } + + secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") + secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") rootCmd.AddCommand(secretsCmd) } diff --git a/cli/packages/util/errors.go b/cli/packages/util/log.go similarity index 88% rename from cli/packages/util/errors.go rename to cli/packages/util/log.go index 1761d6f7b..a701987fc 100644 --- a/cli/packages/util/errors.go +++ b/cli/packages/util/log.go @@ -23,6 +23,10 @@ func PrintErrorAndExit(exitCode int, err error, messages ...string) { os.Exit(exitCode) } +func PrintWarning(message string) { + color.Yellow("Warning: %v", message) +} + func PrintMessageAndExit(messages ...string) { if len(messages) > 0 { for _, message := range messages { diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 131a8e66d..c0a3ad952 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -117,9 +117,11 @@ func GetAllEnvironmentVariables(envName string) ([]models.SingleEnvironmentVaria secrets, err := GetPlainTextSecretsViaJTW(loggedInUserDetails.UserCredentials.JTWToken, loggedInUserDetails.UserCredentials.PrivateKey, workspaceFile.WorkspaceId, envName) return secrets, err - } else { + } else if infisicalToken != "" { log.Debug("Trying to fetch secrets using service token") return GetPlainTextSecretsViaServiceToken(infisicalToken) + } else { + return nil, fmt.Errorf("unable to fetch secrets because we could not find a service token or a logged in user") } } diff --git a/cli/packages/util/vault.go b/cli/packages/util/vault.go index 5561f2aee..7e4ce1b31 100644 --- a/cli/packages/util/vault.go +++ b/cli/packages/util/vault.go @@ -33,6 +33,7 @@ func GetKeyRing() (keyring.Keyring, error) { LibSecretCollectionName: KEYRING_SERVICE_NAME, KWalletAppID: KEYRING_SERVICE_NAME, KWalletFolder: KEYRING_SERVICE_NAME, + KeychainName: "login", // default so user will not be prompted KeychainTrustApplication: true, WinCredPrefix: KEYRING_SERVICE_NAME, FileDir: fmt.Sprintf("~/%s-file-vault", KEYRING_SERVICE_NAME), diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9b0838209..014d858c4 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -50,6 +50,7 @@ services: - ./frontend/public:/app/public - ./frontend/styles:/app/styles - ./frontend/components:/app/components + - ./frontend/ee:/app/ee - ./frontend/locales:/app/locales - ./frontend/next-i18next.config.js:/app/next-i18next.config.js env_file: .env diff --git a/docs/getting-started/dashboard/audit-logs.mdx b/docs/getting-started/dashboard/audit-logs.mdx index bb31423b4..808a7112d 100644 --- a/docs/getting-started/dashboard/audit-logs.mdx +++ b/docs/getting-started/dashboard/audit-logs.mdx @@ -2,8 +2,10 @@ title: "Activity Logs" --- -Activity logs record all actions going through Infisical including CRUD operations applied to environment variables. They help answer questions like: +Activity logs record all actions going through Infisical including who performed which CRUD operations on environment variables and from what IP address. They help answer questions like: - Who added or updated environment variables recently? - Did Bob read environment variables last week (if at all)? - What IP address was used for that action? + +![Activity logs](../../images/activity-logs.png) diff --git a/docs/getting-started/dashboard/pit-recovery.mdx b/docs/getting-started/dashboard/pit-recovery.mdx index 534cc2718..8881f117f 100644 --- a/docs/getting-started/dashboard/pit-recovery.mdx +++ b/docs/getting-started/dashboard/pit-recovery.mdx @@ -2,4 +2,22 @@ title: "Point-in-Time Recovery" --- -Point-in-time (PIT) recovery allows environment variables to be rolled back to any point in time. It's powered by snapshots that get captured after mutations to environment variables. +Point-in-time recovery allows environment variables to be rolled back to any point in time. It's powered by snapshots that get captured after mutations to environment variables. + +## Commits + +Similar to Git, a commit in Infisical is a snapshot of your project's secrets at a specific point in time. You can browse and view your project's snapshots via the "Point-in-Time Recovery" sidebar. + +![PIT commits](../../images/pit-commits.png) +![PIT snapshots](../../images/pit-snapshots.png) + +## Rolling back + +Environment variables can be rolled back to any point in time via the "Rollback to this snapshot" button. + +![PIT snapshot](../../images/pit-snapshot.png) + + + Rolling back environment variables to a past snapshot creates a new commit and + snapshot at the top of the stack and updates secret versions. + diff --git a/docs/getting-started/dashboard/secret-versioning.mdx b/docs/getting-started/dashboard/secret-versioning.mdx new file mode 100644 index 000000000..ccf94fef1 --- /dev/null +++ b/docs/getting-started/dashboard/secret-versioning.mdx @@ -0,0 +1,14 @@ +--- +title: "Secret Versioning" +--- + +Secret versioning records changes made to every secret. + +![secret versioning](../../images/secret-versioning.png) + + + You can copy and paste a secret version value to the "Value" input field "roll + back" to that secret version. This creates a new secret version at the top of + the stack. We're releasing the ability to press and automatically roll back to + a secret version soon. + diff --git a/docs/getting-started/dashboard/versioning.mdx b/docs/getting-started/dashboard/versioning.mdx deleted file mode 100644 index 3a6ba2e2c..000000000 --- a/docs/getting-started/dashboard/versioning.mdx +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: "Secret Versioning" ---- - -Secret versioning allows an individual environment variable to be rolled back without touching other project environment variables. diff --git a/docs/images/activity-logs.png b/docs/images/activity-logs.png new file mode 100644 index 000000000..29349c82b Binary files /dev/null and b/docs/images/activity-logs.png differ diff --git a/docs/images/pit-commits.png b/docs/images/pit-commits.png new file mode 100644 index 000000000..19cfa4976 Binary files /dev/null and b/docs/images/pit-commits.png differ diff --git a/docs/images/pit-snapshot.png b/docs/images/pit-snapshot.png new file mode 100644 index 000000000..7e790e875 Binary files /dev/null and b/docs/images/pit-snapshot.png differ diff --git a/docs/images/pit-snapshots.png b/docs/images/pit-snapshots.png new file mode 100644 index 000000000..f22231648 Binary files /dev/null and b/docs/images/pit-snapshots.png differ diff --git a/docs/images/secret-versioning.png b/docs/images/secret-versioning.png new file mode 100644 index 000000000..ec1734289 Binary files /dev/null and b/docs/images/secret-versioning.png differ diff --git a/docs/mint.json b/docs/mint.json index d4146ff9b..4b83b5c17 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -39,11 +39,6 @@ "icon": "server", "url": "self-hosting" }, - { - "name": "API Reference", - "icon": "cloud", - "url": "self-hosting" - }, { "name": "Integrations", "icon": "plug", @@ -86,7 +81,7 @@ "getting-started/dashboard/project", "getting-started/dashboard/integrations", "getting-started/dashboard/pit-recovery", - "getting-started/dashboard/versioning", + "getting-started/dashboard/secret-versioning", "getting-started/dashboard/audit-logs", "getting-started/dashboard/token" ] diff --git a/frontend/components/basic/Layout.tsx b/frontend/components/basic/Layout.tsx index ef8eda727..48399cdce 100644 --- a/frontend/components/basic/Layout.tsx +++ b/frontend/components/basic/Layout.tsx @@ -19,7 +19,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import getOrganizations from "~/pages/api/organization/getOrgs"; import getOrganizationUserProjects from "~/pages/api/organization/GetOrgUserProjects"; import getOrganizationUsers from "~/pages/api/organization/GetOrgUsers"; -import checkUserAction from "~/pages/api/userActions/checkUserAction"; +import getUser from "~/pages/api/user/getUser"; import addUserToWorkspace from "~/pages/api/workspace/addUserToWorkspace"; import createWorkspace from "~/pages/api/workspace/createWorkspace"; import getWorkspaces from "~/pages/api/workspace/getWorkspaces"; @@ -39,6 +39,7 @@ import Listbox from "./Listbox"; interface LayoutProps { children: React.ReactNode; } +const crypto = require("crypto"); export default function Layout({ children }: LayoutProps) { const router = useRouter(); @@ -83,12 +84,31 @@ export default function Layout({ children }: LayoutProps) { }); const newWorkspaceId = newWorkspace._id; + const randomBytes = crypto.randomBytes(16).toString("hex"); + const PRIVATE_KEY = String(localStorage.getItem("PRIVATE_KEY")); + + const myUser = await getUser(); + + const { ciphertext, nonce } = encryptAssymmetric({ + plaintext: randomBytes, + publicKey: myUser.publicKey, + privateKey: PRIVATE_KEY, + }) as { ciphertext: string; nonce: string }; + + await uploadKeys( + newWorkspaceId, + myUser._id, + ciphertext, + nonce + ); + if (addAllUsers) { + console.log('adding other users') const orgUsers = await getOrganizationUsers({ orgId: tempLocalStorage("orgData.id"), }); orgUsers.map(async (user: any) => { - if (user.status == "accepted") { + if (user.status == "accepted" && user.email != myUser.email) { const result = await addUserToWorkspace( user.user.email, newWorkspaceId diff --git a/frontend/components/basic/Toggle.tsx b/frontend/components/basic/Toggle.tsx index d15aed622..c9957cc3c 100644 --- a/frontend/components/basic/Toggle.tsx +++ b/frontend/components/basic/Toggle.tsx @@ -34,7 +34,7 @@ interface ToggleProps { * @param {string} obj.value - value of a certain secret * @param {number} obj.pos - position of a certain secret #TODO: make the secret id persistent? - * @param {string} obj.id - id of a certain secret + * @param {string} obj.id - id of a certain secret (NOTE: THIS IS THE ID OF THE MAIN SECRET - NOT OF AN OVERRIDE) * @param {function} obj.deleteOverride - a function that deleted an override for a certain secret * @param {string[]} obj.sharedToHide - an array of shared secrets that we want to hide visually because they are overriden. * @param {function} obj.setSharedToHide - a function that updates the array of secrets that we want to hide visually diff --git a/frontend/components/basic/popups/BottomRightPopup.tsx b/frontend/components/basic/popups/BottomRightPopup.tsx index e6ea60f92..71b4f4a66 100644 --- a/frontend/components/basic/popups/BottomRightPopup.tsx +++ b/frontend/components/basic/popups/BottomRightPopup.tsx @@ -36,7 +36,7 @@ export default function BottonRightPopup({ }: PopupProps): JSX.Element { return (
diff --git a/frontend/components/dashboard/CommentField.tsx b/frontend/components/dashboard/CommentField.tsx index ea29aa73c..62ff0c2f1 100644 --- a/frontend/components/dashboard/CommentField.tsx +++ b/frontend/components/dashboard/CommentField.tsx @@ -6,10 +6,10 @@ import { useTranslation } from "next-i18next"; const CommentField = ({ comment, modifyComment, position }: { comment: string; modifyComment: (value: string, posistion: number) => void; position: number;}) => { const { t } = useTranslation(); - return
-

{t("dashboard:sidebar.comments")}

+ return
+

{t("dashboard:sidebar.comments")}