From 9f9273bb0201d585aa6fde326c22378833294a7b Mon Sep 17 00:00:00 2001 From: Maidul Islam Date: Sun, 5 Feb 2023 12:54:27 -0800 Subject: [PATCH] Add tags support for secrets --- backend/src/app.ts | 2 + backend/src/controllers/v2/index.ts | 4 +- .../src/controllers/v2/secretsController.ts | 58 +++++++++------- backend/src/controllers/v2/tagController.ts | 66 +++++++++++++++++++ backend/src/ee/models/secretVersion.ts | 8 ++- backend/src/models/secret.ts | 6 ++ backend/src/models/tag.ts | 49 ++++++++++++++ backend/src/routes/v2/index.ts | 4 +- backend/src/routes/v2/tags.ts | 50 ++++++++++++++ 9 files changed, 221 insertions(+), 26 deletions(-) create mode 100644 backend/src/controllers/v2/tagController.ts create mode 100644 backend/src/models/tag.ts create mode 100644 backend/src/routes/v2/tags.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 79561d00c0..cf44804e98 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -50,6 +50,7 @@ import { serviceTokenData as v2ServiceTokenDataRouter, apiKeyData as v2APIKeyDataRouter, environment as v2EnvironmentRouter, + tags as v2TagsRouter, } from './routes/v2'; import { healthCheck } from './routes/status'; @@ -112,6 +113,7 @@ app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); app.use('/api/v2/users', v2UsersRouter); app.use('/api/v2/organizations', v2OrganizationsRouter); app.use('/api/v2/workspace', v2EnvironmentRouter); +app.use('/api/v2/workspace', v2TagsRouter); app.use('/api/v2/workspace', v2WorkspaceRouter); app.use('/api/v2/secret', v2SecretRouter); // deprecated app.use('/api/v2/secrets', v2SecretsRouter); diff --git a/backend/src/controllers/v2/index.ts b/backend/src/controllers/v2/index.ts index 936f5e2819..3183ac60f5 100644 --- a/backend/src/controllers/v2/index.ts +++ b/backend/src/controllers/v2/index.ts @@ -6,6 +6,7 @@ import * as apiKeyDataController from './apiKeyDataController'; import * as secretController from './secretController'; import * as secretsController from './secretsController'; import * as environmentController from './environmentController'; +import * as tagController from './tagController'; export { usersController, @@ -15,5 +16,6 @@ export { apiKeyDataController, secretController, secretsController, - environmentController + environmentController, + tagController } diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 5c6f5898e9..5ac86e9039 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -86,17 +86,28 @@ export const createSecrets = async (req: Request, res: Response) => { throw UnauthorizedRequestError({ message: "You do not have the necessary permission(s) perform this action" }) } - let toAdd; + let listOfSecretsToCreate; if (Array.isArray(req.body.secrets)) { // case: create multiple secrets - toAdd = req.body.secrets; + listOfSecretsToCreate = req.body.secrets; } else if (typeof req.body.secrets === 'object') { // case: create 1 secret - toAdd = [req.body.secrets]; + listOfSecretsToCreate = [req.body.secrets]; } - const newSecrets = await Secret.insertMany( - toAdd.map(({ + type secretsToCreateType = { + type: string; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + tags: string[] + } + + const newlyCreatedSecrets = await Secret.insertMany( + listOfSecretsToCreate.map(({ type, secretKeyCiphertext, secretKeyIV, @@ -104,15 +115,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, - }: { - type: string; - secretKeyCiphertext: string; - secretKeyIV: string; - secretKeyTag: string; - secretValueCiphertext: string; - secretValueIV: string; - secretValueTag: string; - }) => { + tags + }: secretsToCreateType) => { return ({ version: 1, workspace: new Types.ObjectId(workspaceId), @@ -124,7 +128,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + tags }); }) ); @@ -140,7 +145,7 @@ export const createSecrets = async (req: Request, res: Response) => { // (EE) add secret versions for new secrets await EESecretService.addSecretVersions({ - secretVersions: newSecrets.map(({ + secretVersions: newlyCreatedSecrets.map(({ _id, version, workspace, @@ -154,7 +159,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash + secretValueHash, + tags }) => ({ _id: new Types.ObjectId(), secret: _id, @@ -171,7 +177,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, - secretValueHash + secretValueHash, + tags })) }); @@ -179,7 +186,7 @@ export const createSecrets = async (req: Request, res: Response) => { name: ACTION_ADD_SECRETS, userId: req.user._id, workspaceId: new Types.ObjectId(workspaceId), - secretIds: newSecrets.map((n) => n._id) + secretIds: newlyCreatedSecrets.map((n) => n._id) }); // (EE) create (audit) log @@ -201,7 +208,7 @@ export const createSecrets = async (req: Request, res: Response) => { event: 'secrets added', distinctId: req.user.email, properties: { - numberOfSecrets: toAdd.length, + numberOfSecrets: listOfSecretsToCreate.length, environment, workspaceId, channel: channel, @@ -211,7 +218,7 @@ export const createSecrets = async (req: Request, res: Response) => { } return res.status(200).send({ - secrets: newSecrets + secrets: newlyCreatedSecrets }); } @@ -294,7 +301,7 @@ export const getSecrets = async (req: Request, res: Response) => { ], type: { $in: [SECRET_SHARED, SECRET_PERSONAL] } } - ).then()) + ).populate("tags").then()) if (err) throw ValidationError({ message: 'Failed to get secrets', stack: err.stack }); @@ -398,6 +405,7 @@ export const updateSecrets = async (req: Request, res: Response) => { secretCommentCiphertext: string; secretCommentIV: string; secretCommentTag: string; + tags: string[] } const updateOperationsToPerform = req.body.secrets.map((secret: PatchSecret) => { @@ -410,7 +418,8 @@ export const updateSecrets = async (req: Request, res: Response) => { secretValueTag, secretCommentCiphertext, secretCommentIV, - secretCommentTag + secretCommentTag, + tags } = secret; return ({ @@ -426,6 +435,7 @@ export const updateSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, + tags, ...(( secretCommentCiphertext && secretCommentIV && @@ -460,6 +470,7 @@ export const updateSecrets = async (req: Request, res: Response) => { secretCommentCiphertext, secretCommentIV, secretCommentTag, + tags } = secretModificationsBySecretId[secret._id.toString()] return ({ @@ -477,6 +488,7 @@ export const updateSecrets = async (req: Request, res: Response) => { secretCommentCiphertext: secretCommentCiphertext ? secretCommentCiphertext : secret.secretCommentCiphertext, secretCommentIV: secretCommentIV ? secretCommentIV : secret.secretCommentIV, secretCommentTag: secretCommentTag ? secretCommentTag : secret.secretCommentTag, + tags: tags ? tags : secret.tags }); }) } diff --git a/backend/src/controllers/v2/tagController.ts b/backend/src/controllers/v2/tagController.ts new file mode 100644 index 0000000000..250ee08a5b --- /dev/null +++ b/backend/src/controllers/v2/tagController.ts @@ -0,0 +1,66 @@ +import { Request, Response } from 'express'; +import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; +import { + Membership, +} from '../../models'; +import Tag, { ITag } from '../../models/tag'; +import { Builder } from "builder-pattern" +import to from 'await-to-js'; +import { BadRequestError, UnauthorizedRequestError } from '../../utils/errors'; +import { MongoError } from 'mongodb'; +import { userHasWorkspaceAccess } from '../../ee/helpers/checkMembershipPermissions'; + +export const createWorkspaceTag = async (req: Request, res: Response) => { + const { workspaceId } = req.params + const { name, slug } = req.body + const sanitizedTagToCreate = Builder() + .name(name) + .workspace(new Types.ObjectId(workspaceId)) + .slug(slug) + .user(new Types.ObjectId(req.user._id)) + .build(); + + const [err, createdTag] = await to(Tag.create(sanitizedTagToCreate)) + + if (err) { + if ((err as MongoError).code === 11000) { + throw BadRequestError({ message: "Tags must be unique in a workspace" }) + } + + throw err + } + + res.json(createdTag) +} + +export const deleteWorkspaceTag = async (req: Request, res: Response) => { + const { tagId } = req.params + + const tagFromDB = await Tag.findById(tagId) + if (!tagFromDB) { + throw BadRequestError() + } + + // can only delete if the request user is one that belongs to the same workspace as the tag + const membership = await Membership.findOne({ + user: req.user, + workspace: tagFromDB.workspace + }); + + if (!membership) { + UnauthorizedRequestError({ message: 'Failed to validate membership' }); + } + + await Tag.findByIdAndDelete(tagId) + + res.sendStatus(200) +} + +export const getWorkspaceTags = async (req: Request, res: Response) => { + const { workspaceId } = req.params + const workspaceTags = await Tag.find({ workspace: workspaceId }) + return res.json({ + workspaceTags + }) +} \ No newline at end of file diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 1af4aff2c3..efa042765f 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -21,6 +21,7 @@ export interface ISecretVersion { secretValueIV: string; secretValueTag: string; secretValueHash: string; + tags?: string[]; } const secretVersionSchema = new Schema( @@ -88,7 +89,12 @@ const secretVersionSchema = new Schema( }, secretValueHash: { type: String - } + }, + tags: { + ref: 'Tag', + type: [Schema.Types.ObjectId], + default: [] + }, }, { timestamps: true diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index 6887c8b0f6..4ac6c768d0 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -23,6 +23,7 @@ export interface ISecret { secretCommentIV?: string; secretCommentTag?: string; secretCommentHash?: string; + tags?: string[]; } const secretSchema = new Schema( @@ -47,6 +48,11 @@ const secretSchema = new Schema( type: Schema.Types.ObjectId, ref: 'User' }, + tags: { + ref: 'Tag', + type: [Schema.Types.ObjectId], + default: [] + }, environment: { type: String, required: true diff --git a/backend/src/models/tag.ts b/backend/src/models/tag.ts new file mode 100644 index 0000000000..6b02c8b1bc --- /dev/null +++ b/backend/src/models/tag.ts @@ -0,0 +1,49 @@ +import { Schema, model, Types } from 'mongoose'; + +export interface ITag { + _id: Types.ObjectId; + name: string; + slug: string; + user: Types.ObjectId; + workspace: Types.ObjectId; +} + +const tagSchema = new Schema( + { + name: { + type: String, + required: true, + trim: true, + }, + slug: { + type: String, + required: true, + trim: true, + lowercase: true, + validate: [ + function (value: any) { + return value.indexOf(' ') === -1; + }, + 'slug cannot contain spaces' + ] + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User' + }, + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace' + }, + }, + { + timestamps: true + } +); + +tagSchema.index({ slug: 1, workspace: 1 }, { unique: true }) +tagSchema.index({ workspace: 1 }) + +const Tag = model('Tag', tagSchema); + +export default Tag; diff --git a/backend/src/routes/v2/index.ts b/backend/src/routes/v2/index.ts index 6f698e316a..dfc9ee617d 100644 --- a/backend/src/routes/v2/index.ts +++ b/backend/src/routes/v2/index.ts @@ -6,6 +6,7 @@ import secrets from './secrets'; import serviceTokenData from './serviceTokenData'; import apiKeyData from './apiKeyData'; import environment from "./environment" +import tags from "./tags" export { users, @@ -15,5 +16,6 @@ export { secrets, serviceTokenData, apiKeyData, - environment + environment, + tags } \ No newline at end of file diff --git a/backend/src/routes/v2/tags.ts b/backend/src/routes/v2/tags.ts new file mode 100644 index 0000000000..d78e1e0f1e --- /dev/null +++ b/backend/src/routes/v2/tags.ts @@ -0,0 +1,50 @@ +import express, { Response, Request } from 'express'; +const router = express.Router(); +import { body, param } from 'express-validator'; +import { tagController } from '../../controllers/v2'; +import { + requireAuth, + requireWorkspaceAuth, + validateRequest, +} from '../../middleware'; +import { ADMIN, MEMBER } from '../../variables'; + +router.get( + '/:workspaceId/tags', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + requireWorkspaceAuth({ + acceptedRoles: [MEMBER, ADMIN], + }), + param('workspaceId').exists().trim(), + validateRequest, + tagController.getWorkspaceTags +); + +router.delete( + '/tags/:tagId', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + param('tagId').exists().trim(), + validateRequest, + tagController.deleteWorkspaceTag +); + +router.post( + '/:workspaceId/tags', + requireAuth({ + acceptedAuthModes: ['jwt'], + }), + requireWorkspaceAuth({ + acceptedRoles: [MEMBER, ADMIN], + }), + param('workspaceId').exists().trim(), + body('name').exists().trim(), + body('slug').exists().trim(), + validateRequest, + tagController.createWorkspaceTag +); + +export default router;