Finish preliminary /v2/secrets routes for batch/single CRUD secrets endpoints

This commit is contained in:
Tuan Dang
2023-01-09 01:03:40 +07:00
parent 7e534629ff
commit 35d23cf55c
14 changed files with 918 additions and 171 deletions

View File

@ -38,6 +38,7 @@ import {
} from './routes/v1';
import {
secret as v2SecretRouter,
secrets as v2SecretsRouter,
workspace as v2WorkspaceRouter,
serviceTokenData as v2ServiceTokenDataRouter,
apiKeyData as v2APIKeyDataRouter,
@ -91,16 +92,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);

View File

@ -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
}

View File

@ -7,13 +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,
@ -28,13 +31,13 @@ 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 })
}
@ -45,20 +48,27 @@ export const createSingleSecret = async (req: Request, res: Response) => {
distinctId: req.user.email,
properties: {
numberOfSecrets: 1,
environment: environmentName,
workspaceId,
// #TODO: why does this route have no channel?
// channel: channel ? channel : 'cli'
environment,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
}
res.status(200).send()
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 => {
@ -76,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)
}
@ -84,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 })
@ -99,18 +109,25 @@ export const batchCreateSecrets = async (req: Request, res: Response) => {
distinctId: req.user.email,
properties: {
numberOfSecrets: (secretsToCreate ?? []).length,
environment: environmentName,
workspaceId,
// #TODO: why does this route have no channel?
// channel: channel ? channel : 'cli'
environment,
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
res.status(200).send()
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
@ -149,8 +166,8 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => {
numberOfSecrets: numSecretsDeleted,
environment: environmentName,
workspaceId,
// #TODO: why does this route have no channel?
// channel: channel ? channel : 'cli'
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
@ -158,49 +175,40 @@ export const batchDeleteSecrets = async (req: Request, res: Response) => {
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)
if (postHogClient) {
postHogClient.capture({
event: 'secrets deleted',
distinctId: req.user.email,
properties: {
numberOfSecrets: 1,
// #TODO: how do we get env name ans project id?
// environment: environmentName,
// workspaceId,
// #TODO: why does this route have no channel?
// channel: channel ? channel : 'cli'
}
});
}
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())
@ -252,8 +260,8 @@ export const batchModifySecrets = async (req: Request, res: Response) => {
numberOfSecrets: (secretsModificationsRequested ?? []).length,
environment: environmentName,
workspaceId,
// #TODO: why does this route have no channel?
// channel: channel ? channel : 'cli'
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
@ -261,8 +269,13 @@ export const batchModifySecrets = async (req: Request, res: Response) => {
return res.status(200).send()
}
// #TODO: I assume this should be '...Secret'?
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;
@ -299,8 +312,8 @@ export const modifySingleSecrets = async (req: Request, res: Response) => {
numberOfSecrets: 1,
environment: environmentName,
workspaceId,
// #TODO: why does this route have no channel?
// channel: channel ? channel : 'cli'
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
@ -308,11 +321,18 @@ export const modifySingleSecrets = async (req: Request, res: Response) => {
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();
}
@ -321,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,
@ -330,8 +350,8 @@ 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 })
}
if (postHogClient) {
@ -339,56 +359,40 @@ export const fetchAllSecrets = async (req: Request, res: Response) => {
event: 'secrets pulled',
distinctId: req.user.email,
properties: {
numberOfSecrets: (allSecrets ?? []).length,
numberOfSecrets: (secrets ?? []).length,
environment,
workspaceId,
// #TODO: why does this route have no channel?
// channel: channel ? channel : 'cli'
channel: req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli',
userAgent: req.headers?.['user-agent']
}
});
}
return res.json(allSecrets)
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)
if (postHogClient) {
postHogClient.capture({
event: 'secrets pulled',
distinctId: req.user.email,
properties: {
numberOfSecrets: 1,
// #TODO: how do we get environment and workspace here? Do we need that? When is this route used?
// environment,
// workspaceId,
// #TODO: why does this route have no channel?
// channel: channel ? channel : 'cli'
}
});
}
} 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
});
}

View File

@ -0,0 +1,472 @@
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';
/**
* 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 deleted',
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
});
return res.status(200).send({
secrets
});
}
/**
* Update secret(s) in workspace with id [workspaceId] and environment [environment]
* @param req
* @param res
*/
export const updateSecrets = async (req: Request, res: Response) => {
const channel = req.headers?.['user-agent']?.toLowerCase().includes('mozilla') ? 'web' : 'cli';
const { workspaceId, environment } = req.body;
// 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
} : {}),
}
}
});
});
const b = await Secret.bulkWrite(ops);
let 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
let 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_DELETE_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 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']
}
});
}
});
const updateAction = await EELogService.createActionSecret({
name: ACTION_UPDATE_SECRETS,
userId: req.user._id.toString(),
workspaceId,
secretIds: req.secrets.map((secret: ISecret) => secret._id)
});
// (EE) create (audit) log
updateAction && await EELogService.createLog({
userId: req.user._id.toString(),
workspaceId,
actions: [updateAction],
channel,
ipAddress: req.ip
});
// (EE) take a secret snapshot
await EESecretService.takeSecretSnapshot({
workspaceId
});
if (postHogClient) {
postHogClient.capture({
event: 'secrets modified',
distinctId: req.user.email,
properties: {
numberOfSecrets: req.secrets.length,
environment,
workspaceId,
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) in workspace 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
let 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(400).send({
secrets: req.secrets
});
}

View File

@ -86,8 +86,7 @@ const secretVersionSchema = new Schema<ISecretVersion>(
required: true
},
secretKeyHash: {
type: String,
required: true
type: String
},
secretValueCiphertext: {
type: String,
@ -102,8 +101,7 @@ const secretVersionSchema = new Schema<ISecretVersion>(
required: true
},
secretValueHash: {
type: String,
required: true
type: String
}
},
{

View File

@ -3,6 +3,7 @@ import { Types } from 'mongoose';
import {
Secret,
ISecret,
Membership
} from '../models';
import {
EESecretService,
@ -20,6 +21,47 @@ 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) {
console.error(err);
throw new Error('Failed to validate secrets');
}
return secrets;
}
interface V1PushSecret {
ciphertextKey: string;
ivKey: string;
@ -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<ISecret[]> => {
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,

View File

@ -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
};

View File

@ -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) {

View File

@ -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;

View File

@ -69,8 +69,7 @@ const secretSchema = new Schema<ISecret>(
required: true
},
secretKeyHash: {
type: String,
required: true
type: String
},
secretValueCiphertext: {
type: String,
@ -85,8 +84,7 @@ const secretSchema = new Schema<ISecret>(
required: true
},
secretValueHash: {
type: String,
required: true
type: String
},
secretCommentCiphertext: {
type: String,

View File

@ -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

View File

@ -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;

View File

@ -0,0 +1,177 @@
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 conform to the Secret interface');
}
}
} 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 array must contain objects that conform to the Secret interface');
}
} 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('workspaceId').exists().trim(),
body('environment').exists().trim().isIn(['dev', 'staging', 'prod', 'test']),
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 conform to the Secret interface');
}
}
} 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 array must contain objects that conform to the Secret interface');
}
} 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'
}),
requireSecretsAuth({
acceptedRoles: [ADMIN, MEMBER]
}),
secretsController.updateSecrets
);
router.delete(
'/',
[
check('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;

View File

@ -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;