diff --git a/README.md b/README.md index 1882f7f56..486ee10ef 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ git commit activity - Cloudsmith downloads + Cloudsmith downloads Slack community channel @@ -55,7 +55,7 @@ We're on a mission to make secret management more accessible to everyone, not ju - **[Audit logs](https://infisical.com/docs/documentation/platform/audit-logs)** to record every action taken in a project - **Role-based Access Controls** per environment - [**Simple on-premise deployments** to AWS and Digital Ocean](https://infisical.com/docs/self-hosting/overview) -- [**2FA**](https://infisical.com/docs/documentation/platform/mfa) with more options coming soon +- [**Secret Scanning**](https://infisical.com/docs/cli/scanning-overview) And much more. diff --git a/backend/package-lock.json b/backend/package-lock.json index b82659229..ff2b50915 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -33,7 +33,7 @@ "express-validator": "^6.14.2", "handlebars": "^4.7.7", "helmet": "^5.1.1", - "infisical-node": "^1.1.3", + "infisical-node": "^1.2.1", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jsrp": "^0.2.4", @@ -6967,13 +6967,12 @@ } }, "node_modules/infisical-node": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/infisical-node/-/infisical-node-1.1.3.tgz", - "integrity": "sha512-MLcZQ/zdpCYFRbj50Tn4Qm58wSKPQfKc3xX4I0c3NnFZvMGd50wnoG1jkkNKjKiYU5h7QDpOg0XZSvlU7yuG6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/infisical-node/-/infisical-node-1.2.1.tgz", + "integrity": "sha512-zEB0w5+1O0mv9qc68bq4f9jDjrtwdbqjJebnwodgy8U1XZElDXeMDQgSMCtgYan7JRmVlH6s/LM8X7kUF+67ZA==", "dependencies": { "axios": "^1.3.3", "dotenv": "^16.0.3", - "node-cache": "^5.1.2", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" } @@ -18580,13 +18579,12 @@ "dev": true }, "infisical-node": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/infisical-node/-/infisical-node-1.1.3.tgz", - "integrity": "sha512-MLcZQ/zdpCYFRbj50Tn4Qm58wSKPQfKc3xX4I0c3NnFZvMGd50wnoG1jkkNKjKiYU5h7QDpOg0XZSvlU7yuG6g==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/infisical-node/-/infisical-node-1.2.1.tgz", + "integrity": "sha512-zEB0w5+1O0mv9qc68bq4f9jDjrtwdbqjJebnwodgy8U1XZElDXeMDQgSMCtgYan7JRmVlH6s/LM8X7kUF+67ZA==", "requires": { "axios": "^1.3.3", "dotenv": "^16.0.3", - "node-cache": "^5.1.2", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" } diff --git a/backend/package.json b/backend/package.json index efa468d44..137c9d9d1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,7 @@ "express-validator": "^6.14.2", "handlebars": "^4.7.7", "helmet": "^5.1.1", - "infisical-node": "^1.1.3", + "infisical-node": "^1.2.1", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", "jsrp": "^0.2.4", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index f7555248a..d73b17a72 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,12 +1,19 @@ import InfisicalClient from 'infisical-node'; -const client = new InfisicalClient({ +export const client = new InfisicalClient({ token: process.env.INFISICAL_TOKEN! }); export const getPort = async () => (await client.getSecret('PORT')).secretValue || 4000; +export const getEncryptionKey = async () => { + const secretValue = (await client.getSecret('ENCRYPTION_KEY')).secretValue; + return secretValue === '' ? undefined : secretValue; +} +export const getRootEncryptionKey = async () => { + const secretValue = (await client.getSecret('ROOT_ENCRYPTION_KEY')).secretValue; + return secretValue === '' ? undefined : secretValue; +} export const getInviteOnlySignup = async () => (await client.getSecret('INVITE_ONLY_SIGNUP')).secretValue === 'true' -export const getEncryptionKey = async () => (await client.getSecret('ENCRYPTION_KEY')).secretValue; export const getSaltRounds = async () => parseInt((await client.getSecret('SALT_ROUNDS')).secretValue) || 10; export const getJwtAuthLifetime = async () => (await client.getSecret('JWT_AUTH_LIFETIME')).secretValue || '10d'; export const getJwtAuthSecret = async () => (await client.getSecret('JWT_AUTH_SECRET')).secretValue; @@ -46,8 +53,14 @@ export const getSmtpPassword = async () => (await client.getSecret('SMTP_PASSWOR export const getSmtpFromAddress = async () => (await client.getSecret('SMTP_FROM_ADDRESS')).secretValue; export const getSmtpFromName = async () => (await client.getSecret('SMTP_FROM_NAME')).secretValue || 'Infisical'; -export const getLicenseKey = async () => (await client.getSecret('LICENSE_KEY')).secretValue; -export const getLicenseServerKey = async () => (await client.getSecret('LICENSE_SERVER_KEY')).secretValue; +export const getLicenseKey = async () => { + const secretValue = (await client.getSecret('LICENSE_KEY')).secretValue; + return secretValue === '' ? undefined : secretValue; +} +export const getLicenseServerKey = async () => { + const secretValue = (await client.getSecret('LICENSE_SERVER_KEY')).secretValue; + return secretValue === '' ? undefined : secretValue; +} export const getLicenseServerUrl = async () => (await client.getSecret('LICENSE_SERVER_URL')).secretValue || 'https://portal.infisical.com'; // TODO: deprecate from here diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index 0d5947a4f..97032aaa4 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -5,7 +5,7 @@ import { IntegrationAuth, Bot } from '../../models'; -import { INTEGRATION_SET, getIntegrationOptions as getIntegrationOptionsFunc } from '../../variables'; +import { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, INTEGRATION_SET, getIntegrationOptions as getIntegrationOptionsFunc } from '../../variables'; import { IntegrationService } from '../../services'; import { getApps, @@ -129,7 +129,9 @@ export const saveIntegrationAccessToken = async ( integration }, { workspace: new Types.ObjectId(workspaceId), - integration + integration, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }, { new: true, upsert: true diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index e5af676b3..681b65459 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -39,7 +39,7 @@ export const beginEmailSignup = async (req: Request, res: Response) => { error: 'Failed to send email verification code' }); } - + return res.status(200).send({ message: `Sent an email verification code to ${email}` }); diff --git a/backend/src/controllers/v2/secretController.ts b/backend/src/controllers/v2/secretController.ts index 75eff3127..fecba43d6 100644 --- a/backend/src/controllers/v2/secretController.ts +++ b/backend/src/controllers/v2/secretController.ts @@ -6,7 +6,7 @@ import { CreateSecretRequestBody, ModifySecretRequestBody, SanitizedSecretForCre 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 { ALGORITHM_AES_256_GCM, ENCODING_SCHEME_UTF8, SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; import { TelemetryService } from '../../services'; import { User } from "../../models"; import { AccountNotFoundError } from '../../utils/errors'; @@ -36,7 +36,9 @@ export const createSecret = async (req: Request, res: Response) => { workspace: new Types.ObjectId(workspaceId), environment, type: secretToCreate.type, - user: new Types.ObjectId(req.user._id) + user: new Types.ObjectId(req.user._id), + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 } @@ -92,7 +94,9 @@ export const createSecrets = async (req: Request, res: Response) => { workspace: new Types.ObjectId(workspaceId), environment, type: rawSecret.type, - user: new Types.ObjectId(req.user._id) + user: new Types.ObjectId(req.user._id), + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 } sanitizedSecretesToCreate.push(safeUpdateFields) diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index aa738972d..4a5618454 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -7,7 +7,9 @@ import { ACTION_ADD_SECRETS, ACTION_READ_SECRETS, ACTION_UPDATE_SECRETS, - ACTION_DELETE_SECRETS + ACTION_DELETE_SECRETS, + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8 } from '../../variables'; import { UnauthorizedRequestError, WorkspaceNotFoundError } from '../../utils/errors'; import { EventService } from '../../services'; @@ -85,7 +87,9 @@ export const batchSecrets = async (req: Request, res: Response) => { workspace: new Types.ObjectId(workspaceId), path: fullFolderPath, folder: folderId, - secretBlindIndex + secretBlindIndex, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }); break; case 'PATCH': @@ -100,6 +104,8 @@ export const batchSecrets = async (req: Request, res: Response) => { secretBlindIndex, folder: folderId, path: fullFolderPath, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }); break; case 'DELETE': @@ -202,6 +208,8 @@ export const batchSecrets = async (req: Request, res: Response) => { secretCommentCiphertext: u.secretCommentCiphertext, secretCommentIV: u.secretCommentIV, secretCommentTag: u.secretCommentTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, tags: u.tags })); @@ -460,6 +468,8 @@ export const createSecrets = async (req: Request, res: Response) => { secretCommentCiphertext, secretCommentIV, secretCommentTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, tags }); }) @@ -506,7 +516,9 @@ export const createSecrets = async (req: Request, res: Response) => { secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 })) }); @@ -861,6 +873,8 @@ export const updateSecrets = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, tags, ...(( secretCommentCiphertext !== undefined && @@ -914,6 +928,8 @@ export const updateSecrets = async (req: Request, res: Response) => { secretCommentCiphertext: secretCommentCiphertext ? secretCommentCiphertext : secret.secretCommentCiphertext, secretCommentIV: secretCommentIV ? secretCommentIV : secret.secretCommentIV, secretCommentTag: secretCommentTag ? secretCommentTag : secret.secretCommentTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, tags: tags ? tags : secret.tags }); }) diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index cfdd93cf3..8e3b39b54 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -162,6 +162,8 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, + algorithm, + keyEncoding } = oldSecretVersion; // update secret @@ -182,6 +184,8 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretValueCiphertext, secretValueIV, secretValueTag, + algorithm, + keyEncoding }, { new: true @@ -205,7 +209,9 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + algorithm, + keyEncoding }).save(); // take secret snapshot diff --git a/backend/src/ee/helpers/secret.ts b/backend/src/ee/helpers/secret.ts index 0bf172581..c60b1a18c 100644 --- a/backend/src/ee/helpers/secret.ts +++ b/backend/src/ee/helpers/secret.ts @@ -93,52 +93,8 @@ const markDeletedSecretVersionsHelper = async ({ ); }; -/** - * Initialize secret versioning by setting previously unversioned - * secrets to version 1 and begin populating secret versions. - */ -const initSecretVersioningHelper = async () => { - await Secret.updateMany( - { version: { $exists: false } }, - { $set: { version: 1 } } - ); - - const unversionedSecrets: ISecret[] = await Secret.aggregate([ - { - $lookup: { - from: "secretversions", - localField: "_id", - foreignField: "secret", - as: "versions", - }, - }, - { - $match: { - versions: { $size: 0 }, - }, - }, - ]); - - if (unversionedSecrets.length > 0) { - await addSecretVersionsHelper({ - secretVersions: unversionedSecrets.map( - (s, idx) => - new SecretVersion({ - ...s, - secret: s._id, - version: s.version ? s.version : 1, - isDeleted: false, - workspace: s.workspace, - environment: s.environment, - }) - ), - }); - } -}; - export { takeSecretSnapshotHelper, addSecretVersionsHelper, - markDeletedSecretVersionsHelper, - initSecretVersioningHelper, + markDeletedSecretVersionsHelper }; diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index a430834a9..9aca6af1c 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -2,6 +2,9 @@ import { Schema, model, Types } from 'mongoose'; import { SECRET_SHARED, SECRET_PERSONAL, + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 } from '../../variables'; export interface ISecretVersion { @@ -20,6 +23,8 @@ export interface ISecretVersion { secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; + algorithm: 'aes-256-gcm'; + keyEncoding: 'utf8' | 'base64'; } const secretVersionSchema = new Schema( @@ -85,7 +90,20 @@ const secretVersionSchema = new Schema( secretValueTag: { type: String, // symmetric required: true - } + }, + algorithm: { // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true + }, + keyEncoding: { + type: String, + enum: [ + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 + ], + required: true + }, }, { timestamps: true diff --git a/backend/src/ee/services/EESecretService.ts b/backend/src/ee/services/EESecretService.ts index e408e848e..e84e35816 100644 --- a/backend/src/ee/services/EESecretService.ts +++ b/backend/src/ee/services/EESecretService.ts @@ -3,8 +3,7 @@ import { ISecretVersion } from '../models'; import { takeSecretSnapshotHelper, addSecretVersionsHelper, - markDeletedSecretVersionsHelper, - initSecretVersioningHelper + markDeletedSecretVersionsHelper } from '../helpers/secret'; import EELicenseService from './EELicenseService'; @@ -64,15 +63,6 @@ class EESecretService { secretIds }); } - - /** - * Initialize secret versioning by setting previously unversioned - * secrets to version 1 and begin populating secret versions. - */ - static async initSecretVersioning() { - if (!EELicenseService.isLicenseValid) return; - await initSecretVersioningHelper(); - } } export default EESecretService; \ No newline at end of file diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index a52abb709..977a82586 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -41,7 +41,6 @@ const validateAuthMode = ({ headers: { [key: string]: string | string[] | undefined }, acceptedAuthModes: string[] }) => { - // TODO: refactor middleware const apiKey = headers['x-api-key']; const authHeader = headers['authorization']; diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index 96ad452d9..04dfd7e36 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -4,107 +4,26 @@ import { BotKey, Secret, ISecret, - IUser, - User, - IServiceAccount, - ServiceAccount, - IServiceTokenData, - ServiceTokenData, + IUser } from "../models"; import { generateKeyPair, - encryptSymmetric, - decryptSymmetric, - decryptAsymmetric, -} from "../utils/crypto"; + encryptSymmetric128BitHexKeyUTF8, + decryptSymmetric128BitHexKeyUTF8, + decryptAsymmetric +} from '../utils/crypto'; import { SECRET_SHARED, - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY, + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 } from "../variables"; -import { getEncryptionKey } from "../config"; -import { BotNotFoundError, UnauthorizedRequestError } from "../utils/errors"; -import { validateMembership } from "../helpers/membership"; -import { validateUserClientForWorkspace } from "../helpers/user"; -import { validateServiceAccountClientForWorkspace } from "../helpers/serviceAccount"; - -/** - * Validate authenticated clients for bot with id [botId] based - * on any known permissions. - * @param {Object} obj - * @param {Object} obj.authData - authenticated client details - * @param {Types.ObjectId} obj.botId - id of bot to validate against - * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles - */ -const validateClientForBot = async ({ - authData, - botId, - acceptedRoles, -}: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }; - botId: Types.ObjectId; - acceptedRoles: Array<"admin" | "member">; -}) => { - const bot = await Bot.findById(botId); - - if (!bot) throw BotNotFoundError(); - - if ( - authData.authMode === AUTH_MODE_JWT && - authData.authPayload instanceof User - ) { - await validateUserClientForWorkspace({ - user: authData.authPayload, - workspaceId: bot.workspace, - acceptedRoles, - }); - - return bot; - } - - if ( - authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && - authData.authPayload instanceof ServiceAccount - ) { - await validateServiceAccountClientForWorkspace({ - serviceAccount: authData.authPayload, - workspaceId: bot.workspace, - }); - - return bot; - } - - if ( - authData.authMode === AUTH_MODE_SERVICE_TOKEN && - authData.authPayload instanceof ServiceTokenData - ) { - throw UnauthorizedRequestError({ - message: "Failed service token authorization for bot", - }); - } - - if ( - authData.authMode === AUTH_MODE_API_KEY && - authData.authPayload instanceof User - ) { - await validateUserClientForWorkspace({ - user: authData.authPayload, - workspaceId: bot.workspace, - acceptedRoles, - }); - - return bot; - } - - throw BotNotFoundError({ - message: "Failed client authorization for bot", - }); -}; +import { + getEncryptionKey, + getRootEncryptionKey, + client +} from "../config"; +import { InternalServerError } from "../utils/errors"; /** * Create an inactive bot with name [name] for workspace with id [workspaceId] @@ -119,23 +38,52 @@ const createBot = async ({ name: string; workspaceId: Types.ObjectId; }) => { + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + const { publicKey, privateKey } = generateKeyPair(); - const { ciphertext, iv, tag } = encryptSymmetric({ - plaintext: privateKey, - key: await getEncryptionKey(), + + if (rootEncryptionKey) { + const { + ciphertext, + iv, + tag + } = client.encryptSymmetric(privateKey, rootEncryptionKey); + + return await new Bot({ + name, + workspace: workspaceId, + isActive: false, + publicKey, + encryptedPrivateKey: ciphertext, + iv, + tag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_BASE64 + }).save(); + + } else if (encryptionKey) { + const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8({ + plaintext: privateKey, + key: await getEncryptionKey(), + }); + + return await new Bot({ + name, + workspace: workspaceId, + isActive: false, + publicKey, + encryptedPrivateKey: ciphertext, + iv, + tag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }).save(); + } + + throw InternalServerError({ + message: 'Failed to create new bot due to missing encryption key' }); - - const bot = await new Bot({ - name, - workspace: workspaceId, - isActive: false, - publicKey, - encryptedPrivateKey: ciphertext, - iv, - tag, - }).save(); - - return bot; }; /** @@ -161,14 +109,14 @@ const getSecretsHelper = async ({ }); secrets.forEach((secret: ISecret) => { - const secretKey = decryptSymmetric({ + const secretKey = decryptSymmetric128BitHexKeyUTF8({ ciphertext: secret.secretKeyCiphertext, iv: secret.secretKeyIV, tag: secret.secretKeyTag, key, }); - const secretValue = decryptSymmetric({ + const secretValue = decryptSymmetric128BitHexKeyUTF8({ ciphertext: secret.secretValueCiphertext, iv: secret.secretValueIV, tag: secret.secretValueTag, @@ -189,34 +137,54 @@ const getSecretsHelper = async ({ * @returns {String} key - decrypted workspace key */ const getKey = async ({ workspaceId }: { workspaceId: string }) => { + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + const botKey = await BotKey.findOne({ workspace: workspaceId, - }).populate<{ sender: IUser }>("sender", "publicKey"); + }) + .populate<{ sender: IUser }>("sender", "publicKey"); if (!botKey) throw new Error("Failed to find bot key"); const bot = await Bot.findOne({ workspace: workspaceId, - }).select("+encryptedPrivateKey +iv +tag"); + }).select("+encryptedPrivateKey +iv +tag +algorithm +keyEncoding"); if (!bot) throw new Error("Failed to find bot"); if (!bot.isActive) throw new Error("Bot is not active"); - const privateKeyBot = decryptSymmetric({ - ciphertext: bot.encryptedPrivateKey, - iv: bot.iv, - tag: bot.tag, - key: await getEncryptionKey(), - }); + if (rootEncryptionKey && bot.keyEncoding === ENCODING_SCHEME_BASE64) { + // case: encoding scheme is base64 + const privateKeyBot = client.decryptSymmetric(bot.encryptedPrivateKey, rootEncryptionKey, bot.iv, bot.tag); - const key = decryptAsymmetric({ - ciphertext: botKey.encryptedKey, - nonce: botKey.nonce, - publicKey: botKey.sender.publicKey as string, - privateKey: privateKeyBot, - }); + return decryptAsymmetric({ + ciphertext: botKey.encryptedKey, + nonce: botKey.nonce, + publicKey: botKey.sender.publicKey as string, + privateKey: privateKeyBot, + }); + } else if (encryptionKey && bot.keyEncoding === ENCODING_SCHEME_UTF8) { + + // case: encoding scheme is utf8 + const privateKeyBot = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: bot.encryptedPrivateKey, + iv: bot.iv, + tag: bot.tag, + key: encryptionKey + }); + + return decryptAsymmetric({ + ciphertext: botKey.encryptedKey, + nonce: botKey.nonce, + publicKey: botKey.sender.publicKey as string, + privateKey: privateKeyBot, + }); + } - return key; + throw InternalServerError({ + message: "Failed to obtain bot's copy of workspace key needed for bot operations" + }); }; /** @@ -234,7 +202,7 @@ const encryptSymmetricHelper = async ({ plaintext: string; }) => { const key = await getKey({ workspaceId: workspaceId.toString() }); - const { ciphertext, iv, tag } = encryptSymmetric({ + const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8({ plaintext, key, }); @@ -266,7 +234,7 @@ const decryptSymmetricHelper = async ({ tag: string; }) => { const key = await getKey({ workspaceId: workspaceId.toString() }); - const plaintext = decryptSymmetric({ + const plaintext = decryptSymmetric128BitHexKeyUTF8({ ciphertext, iv, tag, @@ -277,9 +245,8 @@ const decryptSymmetricHelper = async ({ }; export { - validateClientForBot, createBot, getSecretsHelper, encryptSymmetricHelper, - decryptSymmetricHelper, + decryptSymmetricHelper }; diff --git a/backend/src/helpers/database.ts b/backend/src/helpers/database.ts index 4bfaf1305..fa8c35155 100644 --- a/backend/src/helpers/database.ts +++ b/backend/src/helpers/database.ts @@ -1,6 +1,4 @@ import mongoose from 'mongoose'; -import { EESecretService } from '../ee/services'; -import { SecretService } from '../services'; import { getLogger } from '../utils/logger'; /** @@ -21,9 +19,7 @@ const initDatabaseHelper = async ({ mongoose.Schema.Types.String.checkRequired(v => typeof v === 'string'); (await getLogger("database")).info("Database connection established"); - - await EESecretService.initSecretVersioning(); - await SecretService.initSecretBlindIndexDataHelper(); + } catch (err) { (await getLogger("database")).error(`Unable to establish Database connection due to the error.\n${err}`); } diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index 46eb22766..4d26bded9 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -3,40 +3,20 @@ import { Types } from 'mongoose'; import { Bot, Integration, - IntegrationAuth, - IUser, - User, - IServiceAccount, - ServiceAccount, - IServiceTokenData, - ServiceTokenData + IntegrationAuth } from '../models'; import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations'; import { BotService } from '../services'; import { - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY, INTEGRATION_VERCEL, - INTEGRATION_NETLIFY + INTEGRATION_NETLIFY, + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8 } from '../variables'; import { UnauthorizedRequestError, - IntegrationAuthNotFoundError, - IntegrationNotFoundError } from '../utils/errors'; import RequestError from '../utils/requestError'; -import { - validateClientForIntegrationAuth -} from '../helpers/integrationAuth'; -import { - validateUserClientForWorkspace -} from '../helpers/user'; -import { - validateServiceAccountClientForWorkspace -} from '../helpers/serviceAccount'; -import { IntegrationService } from '../services'; interface Update { workspace: string; @@ -45,84 +25,6 @@ interface Update { accountId?: string; } -/** - * Validate authenticated clients for integration with id [integrationId] based - * on any known permissions. - * @param {Object} obj - * @param {Object} obj.authData - authenticated client details - * @param {Types.ObjectId} obj.integrationId - id of integration to validate against - * @param {String} obj.environment - (optional) environment in workspace to validate against - * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles - * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint - */ - const validateClientForIntegration = async ({ - authData, - integrationId, - acceptedRoles -}: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }; - integrationId: Types.ObjectId; - acceptedRoles: Array<'admin' | 'member'>; -}) => { - - const integration = await Integration.findById(integrationId); - if (!integration) throw IntegrationNotFoundError(); - - const integrationAuth = await IntegrationAuth - .findById(integration.integrationAuth) - .select( - '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' - ); - - if (!integrationAuth) throw IntegrationAuthNotFoundError(); - - const accessToken = (await IntegrationService.getIntegrationAuthAccess({ - integrationAuthId: integrationAuth._id - })).accessToken; - - if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { - await validateUserClientForWorkspace({ - user: authData.authPayload, - workspaceId: integration.workspace, - acceptedRoles - }); - - return ({ integration, accessToken }); - } - - if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { - await validateServiceAccountClientForWorkspace({ - serviceAccount: authData.authPayload, - workspaceId: integration.workspace - }); - - return ({ integration, accessToken }); - } - - if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { - throw UnauthorizedRequestError({ - message: 'Failed service token authorization for integration' - }); - } - - if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { - await validateUserClientForWorkspace({ - user: authData.authPayload, - workspaceId: integration.workspace, - acceptedRoles - }); - - return ({ integration, accessToken }); - } - - throw UnauthorizedRequestError({ - message: 'Failed client authorization for integration' - }); -} - /** * Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration * named [integration] @@ -400,7 +302,9 @@ const setIntegrationAuthRefreshHelper = async ({ }, { refreshCiphertext: obj.ciphertext, refreshIV: obj.iv, - refreshTag: obj.tag + refreshTag: obj.tag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }, { new: true }); @@ -461,7 +365,9 @@ const setIntegrationAuthAccessHelper = async ({ accessCiphertext: encryptedAccessTokenObj.ciphertext, accessIV: encryptedAccessTokenObj.iv, accessTag: encryptedAccessTokenObj.tag, - accessExpiresAt + accessExpiresAt, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }, { new: true }); @@ -475,7 +381,6 @@ const setIntegrationAuthAccessHelper = async ({ } export { - validateClientForIntegration, handleOAuthExchangeHelper, syncIntegrationsHelper, getIntegrationAuthRefreshHelper, diff --git a/backend/src/helpers/membership.ts b/backend/src/helpers/membership.ts index 503ca9fc6..a78100248 100644 --- a/backend/src/helpers/membership.ts +++ b/backend/src/helpers/membership.ts @@ -2,105 +2,12 @@ import * as Sentry from '@sentry/node'; import { Types } from 'mongoose'; import { Membership, - Key, - IUser, - User, - IServiceAccount, - ServiceAccount, - IServiceTokenData, - ServiceTokenData + Key } from '../models'; import { MembershipNotFoundError, - BadRequestError, - UnauthorizedRequestError + BadRequestError } from '../utils/errors'; -import { - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY -} from '../variables'; -import { - validateUserClientForWorkspace -} from '../helpers/user'; -import { - validateServiceAccountClientForWorkspace -} from '../helpers/serviceAccount'; -import { - validateServiceTokenDataClientForWorkspace -} from '../helpers/serviceTokenData'; - -/** - * Validate authenticated clients for membership with id [membershipId] based - * on any known permissions. - * @param {Object} obj - * @param {Object} obj.authData - authenticated client details - * @param {Types.ObjectId} obj.membershipId - id of membership to validate against - * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspaceRoles - * @returns {Membership} - validated membership - */ -const validateClientForMembership = async ({ - authData, - membershipId, - acceptedRoles -}: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }; - membershipId: Types.ObjectId; - acceptedRoles: Array<'admin' | 'member'>; -}) => { - - const membership = await Membership.findById(membershipId); - - if (!membership) throw MembershipNotFoundError({ - message: 'Failed to find membership' - }); - - if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { - await validateUserClientForWorkspace({ - user: authData.authPayload, - workspaceId: membership.workspace, - acceptedRoles - }); - - return membership; - } - - if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { - await validateServiceAccountClientForWorkspace({ - serviceAccount: authData.authPayload, - workspaceId: membership.workspace - }); - - return membership; - } - - if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: authData.authPayload, - workspaceId: new Types.ObjectId(membership.workspace) - }); - - return membership; - } - - if (authData.authMode == AUTH_MODE_API_KEY && authData.authPayload instanceof User) { - await validateUserClientForWorkspace({ - user: authData.authPayload, - workspaceId: membership.workspace, - acceptedRoles - }); - - return membership; - } - - throw UnauthorizedRequestError({ - message: 'Failed client authorization for membership' - }); -} /** * Validate that user with id [userId] is a member of workspace with id [workspaceId] @@ -230,7 +137,6 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => { }; export { - validateClientForMembership, validateMembership, addMemberships, findMembership, diff --git a/backend/src/helpers/membershipOrg.ts b/backend/src/helpers/membershipOrg.ts index d8b944145..b5f4bb366 100644 --- a/backend/src/helpers/membershipOrg.ts +++ b/backend/src/helpers/membershipOrg.ts @@ -3,95 +3,12 @@ import { MembershipOrg, Workspace, Membership, - Key, - IUser, - User, - IServiceAccount, - ServiceAccount, - IServiceTokenData, - ServiceTokenData + Key } from '../models'; import { MembershipOrgNotFoundError, - BadRequestError, UnauthorizedRequestError } from '../utils/errors'; -import { - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY -} from '../variables'; - -/** - * Validate authenticated clients for organization membership with id [membershipOrgId] based - * on any known permissions. - * @param {Object} obj - * @param {Object} obj.authData - authenticated client details - * @param {Types.ObjectId} obj.membershipOrgId - id of organization membership to validate against - * @param {Array<'owner' | 'admin' | 'member'>} obj.acceptedRoles - accepted organization roles - * @param {MembershipOrg} - validated organization membership - */ -const validateClientForMembershipOrg = async ({ - authData, - membershipOrgId, - acceptedRoles, - acceptedStatuses -}: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }; - membershipOrgId: Types.ObjectId; - acceptedRoles: Array<'owner' | 'admin' | 'member'>; - acceptedStatuses: Array<'invited' | 'accepted'>; -}) => { - const membershipOrg = await MembershipOrg.findById(membershipOrgId); - - if (!membershipOrg) throw MembershipOrgNotFoundError({ - message: 'Failed to find organization membership ' - }); - - if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { - await validateMembershipOrg({ - userId: authData.authPayload._id, - organizationId: membershipOrg.organization, - acceptedRoles, - acceptedStatuses - }); - - return membershipOrg; - } - - if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { - if (!authData.authPayload.organization.equals(membershipOrg.organization)) throw UnauthorizedRequestError({ - message: 'Failed service account client authorization for organization membership' - }); - - return membershipOrg; - } - - if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { - throw UnauthorizedRequestError({ - message: 'Failed service account client authorization for organization membership' - }); - } - - if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { - await validateMembershipOrg({ - userId: authData.authPayload._id, - organizationId: membershipOrg.organization, - acceptedRoles, - acceptedStatuses - }); - - return membershipOrg; - } - - throw UnauthorizedRequestError({ - message: 'Failed client authorization for organization membership' - }); -} /** * Validate that user with id [userId] is a member of organization with id [organizationId] @@ -234,7 +151,6 @@ const deleteMembershipOrg = async ({ }; export { - validateClientForMembershipOrg, validateMembershipOrg, findMembershipOrg, addMembershipsOrg, diff --git a/backend/src/helpers/organization.ts b/backend/src/helpers/organization.ts index 54f050a36..74c5df71d 100644 --- a/backend/src/helpers/organization.ts +++ b/backend/src/helpers/organization.ts @@ -1,21 +1,8 @@ import Stripe from "stripe"; import { Types } from "mongoose"; -import { - IUser, - User, - IServiceAccount, - ServiceAccount, - IServiceTokenData, - ServiceTokenData, -} from "../models"; import { Organization, MembershipOrg } from "../models"; import { - ACCEPTED, - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY, - OWNER, + ACCEPTED } from "../variables"; import { getStripeSecretKey, @@ -23,12 +10,6 @@ import { getStripeProductTeam, getStripeProductStarter, } from "../config"; -import { - UnauthorizedRequestError, - OrganizationNotFoundError, -} from "../utils/errors"; -import { validateUserClientForOrganization } from "../helpers/user"; -import { validateServiceAccountClientForOrganization } from "../helpers/serviceAccount"; import { EELicenseService } from '../ee/services'; @@ -40,88 +21,6 @@ import { licenseKeyRequest } from '../config/request'; -/** - * Validate accepted clients for organization with id [organizationId] - * @param {Object} obj - * @param {Object} obj.authData - authenticated client details - * @param {Types.ObjectId} obj.organizationId - id of organization to validate against - */ -const validateClientForOrganization = async ({ - authData, - organizationId, - acceptedRoles, - acceptedStatuses, -}: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }; - organizationId: Types.ObjectId; - acceptedRoles: Array<"owner" | "admin" | "member">; - acceptedStatuses: Array<"invited" | "accepted">; -}) => { - const organization = await Organization.findById(organizationId); - - if (!organization) { - throw OrganizationNotFoundError({ - message: "Failed to find organization", - }); - } - - if ( - authData.authMode === AUTH_MODE_JWT && - authData.authPayload instanceof User - ) { - const membershipOrg = await validateUserClientForOrganization({ - user: authData.authPayload, - organization, - acceptedRoles, - acceptedStatuses, - }); - - return { organization, membershipOrg }; - } - - if ( - authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && - authData.authPayload instanceof ServiceAccount - ) { - await validateServiceAccountClientForOrganization({ - serviceAccount: authData.authPayload, - organization, - }); - - return { organization }; - } - - if ( - authData.authMode === AUTH_MODE_SERVICE_TOKEN && - authData.authPayload instanceof ServiceTokenData - ) { - throw UnauthorizedRequestError({ - message: "Failed service token authorization for organization", - }); - } - - if ( - authData.authMode === AUTH_MODE_API_KEY && - authData.authPayload instanceof User - ) { - const membershipOrg = await validateUserClientForOrganization({ - user: authData.authPayload, - organization, - acceptedRoles, - acceptedStatuses, - }); - - return { organization, membershipOrg }; - } - - throw UnauthorizedRequestError({ - message: "Failed client authorization for organization", - }); -}; - /** * Create an organization with name [name] * @param {Object} obj @@ -275,8 +174,7 @@ const updateSubscriptionOrgQuantity = async ({ }; export { - validateClientForOrganization, createOrganization, initSubscriptionOrg, - updateSubscriptionOrgQuantity, + updateSubscriptionOrgQuantity }; diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index e75f46e0f..9b81d79c9 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -9,6 +9,8 @@ import { ACTION_UPDATE_SECRETS, ACTION_DELETE_SECRETS, ACTION_READ_SECRETS, + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, } from "../variables"; import _ from "lodash"; import { BadRequestError, UnauthorizedRequestError } from "../utils/errors"; @@ -194,6 +196,8 @@ const v1PushSecrets = async ({ secretValueIV: newSecret.ivValue, secretValueTag: newSecret.tagValue, secretValueHash: newSecret.hashValue, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }); }), }); @@ -225,6 +229,8 @@ const v1PushSecrets = async ({ secretCommentIV: s.ivComment, secretCommentTag: s.tagComment, secretCommentHash: s.hashComment, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }; if (toAdd[idx].type === "personal") { @@ -254,6 +260,8 @@ const v1PushSecrets = async ({ secretValueIV, secretValueTag, secretValueHash, + algorithm, + keyEncoding }) => new SecretVersion({ secret: _id, @@ -271,6 +279,8 @@ const v1PushSecrets = async ({ secretValueIV, secretValueTag, secretValueHash, + algorithm, + keyEncoding }) ), }); @@ -467,6 +477,8 @@ const v2PushSecrets = async ({ workspace: workspaceId, type: toAdd[idx].type, environment, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8, ...(toAdd[idx].type === "personal" ? { user: userId } : {}), })) ); @@ -478,6 +490,8 @@ const v2PushSecrets = async ({ ...secretDocument, secret: secretDocument._id, isDeleted: false, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }); }), }); diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index c184bad9c..562c173fa 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -7,58 +7,35 @@ import { DeleteSecretParams } from '../interfaces/services/SecretService'; import { - AuthData -} from '../interfaces/middleware'; -import { - User, - Workspace, - ServiceAccount, - ServiceTokenData, Secret, ISecret, SecretBlindIndexData, } from '../models'; import { SecretVersion } from '../ee/models'; -import { - validateMembership -} from '../helpers/membership'; -import { - validateUserClientForSecret, - validateUserClientForSecrets -} from '../helpers/user'; -import { - validateServiceTokenDataClientForSecrets, - validateServiceTokenDataClientForWorkspace -} from '../helpers/serviceTokenData'; -import { - validateServiceAccountClientForSecrets, - validateServiceAccountClientForWorkspace -} from '../helpers/serviceAccount'; import { BadRequestError, - UnauthorizedRequestError, SecretNotFoundError, - SecretBlindIndexDataNotFoundError + SecretBlindIndexDataNotFoundError, + InternalServerError } from '../utils/errors'; import { - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY, SECRET_PERSONAL, SECRET_SHARED, ACTION_ADD_SECRETS, ACTION_READ_SECRETS, ACTION_UPDATE_SECRETS, - ACTION_DELETE_SECRETS + ACTION_DELETE_SECRETS, + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 } from '../variables'; import crypto from 'crypto'; import * as argon2 from 'argon2'; -import { - encryptSymmetric, - decryptSymmetric +import { + encryptSymmetric128BitHexKeyUTF8, + decryptSymmetric128BitHexKeyUTF8 } from '../utils/crypto'; -import { getEncryptionKey } from '../config'; +import { getEncryptionKey, client, getRootEncryptionKey } from '../config'; import { TelemetryService } from '../services'; import { EESecretService, @@ -69,199 +46,6 @@ import { getAuthDataPayloadUserObj } from '../utils/auth'; -/** - * Validate authenticated clients for secrets with id [secretId] based - * on any known permissions. - * @param {Object} obj - * @param {Object} obj.authData - authenticated client details - * @param {Types.ObjectId} obj.secretId - id of secret to validate against - * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles - * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint - */ -const validateClientForSecret = async ({ - authData, - secretId, - acceptedRoles, - requiredPermissions -}: { - authData: AuthData; - secretId: Types.ObjectId; - acceptedRoles: Array<'admin' | 'member'>; - requiredPermissions: string[]; -}) => { - const secret = await Secret.findById(secretId); - - if (!secret) throw SecretNotFoundError({ - message: 'Failed to find secret' - }); - - if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { - await validateUserClientForSecret({ - user: authData.authPayload, - secret, - acceptedRoles, - requiredPermissions - }); - - return secret; - } - - if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { - await validateServiceAccountClientForWorkspace({ - serviceAccount: authData.authPayload, - workspaceId: secret.workspace, - environment: secret.environment, - requiredPermissions - }); - - return secret; - } - - if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: authData.authPayload, - workspaceId: secret.workspace, - environment: secret.environment - }); - - return secret; - } - - if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { - await validateUserClientForSecret({ - user: authData.authPayload, - secret, - acceptedRoles, - requiredPermissions - }); - - return secret; - } - - throw UnauthorizedRequestError({ - message: 'Failed client authorization for secret' - }); -} - -/** - * Validate authenticated clients for secrets with ids [secretIds] based - * on any known permissions. - * @param {Object} obj - * @param {Object} obj.authData - authenticated client details - * @param {Types.ObjectId[]} obj.secretIds - id of workspace to validate against - * @param {String} obj.environment - (optional) environment in workspace to validate against - * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles - * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint - */ -const validateClientForSecrets = async ({ - authData, - secretIds, - requiredPermissions -}: { - authData: AuthData; - secretIds: Types.ObjectId[]; - requiredPermissions: string[]; -}) => { - - let secrets: ISecret[] = []; - - secrets = await Secret.find({ - _id: { - $in: secretIds - } - }); - - if (secrets.length != secretIds.length) { - throw BadRequestError({ message: 'Failed to validate non-existent secrets' }) - } - - if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { - await validateUserClientForSecrets({ - user: authData.authPayload, - secrets, - requiredPermissions - }); - - return secrets; - } - - if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { - await validateServiceAccountClientForSecrets({ - serviceAccount: authData.authPayload, - secrets, - requiredPermissions - }); - - return secrets; - } - - if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { - await validateServiceTokenDataClientForSecrets({ - serviceTokenData: authData.authPayload, - secrets, - requiredPermissions - }); - - return secrets; - } - - if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { - await validateUserClientForSecrets({ - user: authData.authPayload, - secrets, - requiredPermissions - }); - - return secrets; - } - - throw UnauthorizedRequestError({ - message: 'Failed client authorization for secrets resource' - }); -} - -/** - * Initialize secret blind index data by setting previously - * un-initialized projects to have secret blind index data - * (Ensures that all projects have associated blind index data) - */ -const initSecretBlindIndexDataHelper = async () => { - const workspaceIdsBlindIndexed = await SecretBlindIndexData.distinct('workspace'); - const workspaceIdsToBlindIndex = await Workspace.distinct('_id', { - _id: { - $nin: workspaceIdsBlindIndexed - } - }); - - const secretBlindIndexDataToInsert = await Promise.all( - workspaceIdsToBlindIndex.map(async (workspaceToBlindIndex) => { - const salt = crypto.randomBytes(16).toString('base64'); - - const { - ciphertext: encryptedSaltCiphertext, - iv: saltIV, - tag: saltTag - } = encryptSymmetric({ - plaintext: salt, - key: await getEncryptionKey() - }); - - const secretBlindIndexData = new SecretBlindIndexData({ - workspace: workspaceToBlindIndex, - encryptedSaltCiphertext, - saltIV, - saltTag - }) - - return secretBlindIndexData; - }) - ); - - if (secretBlindIndexDataToInsert.length > 0) { - await SecretBlindIndexData.insertMany(secretBlindIndexDataToInsert); - } -} - /** * Create secret blind index data containing encrypted blind index [salt] * for workspace with id [workspaceId] @@ -273,26 +57,47 @@ const createSecretBlindIndexDataHelper = async ({ }: { workspaceId: Types.ObjectId; }) => { + // initialize random blind index salt for workspace const salt = crypto.randomBytes(16).toString('base64'); - - const { - ciphertext: encryptedSaltCiphertext, - iv: saltIV, - tag: saltTag - } = encryptSymmetric({ - plaintext: salt, - key: await getEncryptionKey() - }); - - const secretBlindIndexData = await new SecretBlindIndexData({ - workspace: workspaceId, - encryptedSaltCiphertext, - saltIV, - saltTag - }).save(); - - return secretBlindIndexData; + + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + + if (rootEncryptionKey) { + const { + ciphertext: encryptedSaltCiphertext, + iv: saltIV, + tag: saltTag + } = client.encryptSymmetric(salt, rootEncryptionKey); + + return await new SecretBlindIndexData({ + workspace: workspaceId, + encryptedSaltCiphertext, + saltIV, + saltTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_BASE64 + }).save(); + } else { + const { + ciphertext: encryptedSaltCiphertext, + iv: saltIV, + tag: saltTag + } = encryptSymmetric128BitHexKeyUTF8({ + plaintext: salt, + key: encryptionKey + }); + + return await new SecretBlindIndexData({ + workspace: workspaceId, + encryptedSaltCiphertext, + saltIV, + saltTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }).save(); + } } /** @@ -306,22 +111,36 @@ const getSecretBlindIndexSaltHelper = async ({ }: { workspaceId: Types.ObjectId; }) => { - // check if workspace blind index data exists + + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + const secretBlindIndexData = await SecretBlindIndexData.findOne({ workspace: workspaceId - }); + }).select('+algorithm +keyEncoding'); if (!secretBlindIndexData) throw SecretBlindIndexDataNotFoundError(); - - // decrypt workspace salt - const salt = decryptSymmetric({ - ciphertext: secretBlindIndexData.encryptedSaltCiphertext, - iv: secretBlindIndexData.saltIV, - tag: secretBlindIndexData.saltTag, - key: await getEncryptionKey() + + if (rootEncryptionKey && secretBlindIndexData.keyEncoding === ENCODING_SCHEME_BASE64) { + return client.decryptSymmetric( + secretBlindIndexData.encryptedSaltCiphertext, + rootEncryptionKey, + secretBlindIndexData.saltIV, + secretBlindIndexData.saltTag + ); + } else if (encryptionKey && secretBlindIndexData.keyEncoding === ENCODING_SCHEME_UTF8) { + // decrypt workspace salt + return decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secretBlindIndexData.encryptedSaltCiphertext, + iv: secretBlindIndexData.saltIV, + tag: secretBlindIndexData.saltTag, + key: encryptionKey + }); + } + + throw InternalServerError({ + message: 'Failed to obtain workspace salt needed for secret blind indexing' }); - - return salt; } /** @@ -376,7 +195,7 @@ const generateSecretBlindIndexHelper = async ({ if (!secretBlindIndexData) throw SecretBlindIndexDataNotFoundError(); // decrypt workspace salt - const salt = decryptSymmetric({ + const salt = decryptSymmetric128BitHexKeyUTF8({ ciphertext: secretBlindIndexData.encryptedSaltCiphertext, iv: secretBlindIndexData.saltIV, tag: secretBlindIndexData.saltTag, @@ -464,7 +283,9 @@ const createSecretHelper = async ({ secretValueTag, secretCommentCiphertext, secretCommentIV, - secretCommentTag + secretCommentTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }).save(); const secretVersion = new SecretVersion({ @@ -481,7 +302,9 @@ const createSecretHelper = async ({ secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }); // // (EE) add version for new secret @@ -771,7 +594,9 @@ const updateSecretHelper = async ({ secretKeyTag: secret.secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag + secretValueTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 }); // (EE) add version for new secret @@ -932,9 +757,6 @@ const deleteSecretHelper = async ({ } export { - validateClientForSecret, - validateClientForSecrets, - initSecretBlindIndexDataHelper, createSecretBlindIndexDataHelper, getSecretBlindIndexSaltHelper, generateSecretBlindIndexWithSaltHelper, diff --git a/backend/src/helpers/user.ts b/backend/src/helpers/user.ts index 73b87f5d6..a69b72695 100644 --- a/backend/src/helpers/user.ts +++ b/backend/src/helpers/user.ts @@ -1,24 +1,8 @@ -import { Types } from 'mongoose'; import { IUser, - ISecret, - IServiceAccount, User, - Membership, - IOrganization, - Organization, } from '../models'; import { sendMail } from './nodemailer'; -import { validateMembership } from './membership'; -import _ from 'lodash'; -import { BadRequestError, UnauthorizedRequestError } from '../utils/errors'; -import { - validateMembershipOrg -} from '../helpers/membershipOrg'; -import { - PERMISSION_READ_SECRETS, - PERMISSION_WRITE_SECRETS -} from '../variables'; /** * Initialize a user under email [email] @@ -26,7 +10,7 @@ import { * @param {String} obj.email - email of user to initialize * @returns {Object} user - the initialized user */ -const setupAccount = async ({ email }: { email: string }) => { +export const setupAccount = async ({ email }: { email: string }) => { const user = await new User({ email }).save(); @@ -52,7 +36,7 @@ const setupAccount = async ({ email }: { email: string }) => { * @param {String} obj.verifier - verifier for auth SRP * @returns {Object} user - the completed user */ -const completeAccount = async ({ +export const completeAccount = async ({ userId, firstName, lastName, @@ -113,7 +97,7 @@ const completeAccount = async ({ * @param {String} obj.ip - login ip address * @param {String} obj.userAgent - login user-agent */ -const checkUserDevice = async ({ +export const checkUserDevice = async ({ user, ip, userAgent @@ -148,206 +132,4 @@ const checkUserDevice = async ({ } }); } -} - -/** - * Validate that user (client) can access workspace - * with id [workspaceId] and its environment [environment] with required permissions - * [requiredPermissions] - * @param {Object} obj - * @param {User} obj.user - user client - * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against - * @param {String} environment - (optional) environment in workspace to validate against - * @param {String[]} requiredPermissions - required permissions as part of the endpoint - */ -const validateUserClientForWorkspace = async ({ - user, - workspaceId, - environment, - acceptedRoles, - requiredPermissions -}: { - user: IUser; - workspaceId: Types.ObjectId; - environment?: string; - acceptedRoles: Array<'admin' | 'member'>; - requiredPermissions?: string[]; -}) => { - - // validate user membership in workspace - const membership = await validateMembership({ - userId: user._id, - workspaceId, - acceptedRoles - }); - - let runningIsDisallowed = false; - requiredPermissions?.forEach((requiredPermission: string) => { - switch (requiredPermission) { - case PERMISSION_READ_SECRETS: - runningIsDisallowed = _.some(membership.deniedPermissions, { environmentSlug: environment, ability: PERMISSION_READ_SECRETS }); - break; - case PERMISSION_WRITE_SECRETS: - runningIsDisallowed = _.some(membership.deniedPermissions, { environmentSlug: environment, ability: PERMISSION_WRITE_SECRETS }); - break; - default: - break; - } - - if (runningIsDisallowed) { - throw UnauthorizedRequestError({ - message: `Failed permissions authorization for workspace environment action : ${requiredPermission}` - }); - } - }); - - return membership; -} - -/** - * Validate that user (client) can access secret [secret] - * with required permissions [requiredPermissions] - * @param {Object} obj - * @param {User} obj.user - user client - * @param {Secret[]} obj.secrets - secrets to validate against - * @param {String[]} requiredPermissions - required permissions as part of the endpoint - */ -const validateUserClientForSecret = async ({ - user, - secret, - acceptedRoles, - requiredPermissions -}: { - user: IUser; - secret: ISecret; - acceptedRoles?: Array<'admin' | 'member'>; - requiredPermissions?: string[]; -}) => { - const membership = await validateMembership({ - userId: user._id, - workspaceId: secret.workspace, - acceptedRoles - }); - - if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { - const isDisallowed = _.some(membership.deniedPermissions, { environmentSlug: secret.environment, ability: PERMISSION_WRITE_SECRETS }); - - if (isDisallowed) { - throw UnauthorizedRequestError({ - message: 'You do not have the required permissions to perform this action' - }); - } - } -} - -/** - * Validate that user (client) can access secrets [secrets] - * with required permissions [requiredPermissions] - * @param {Object} obj - * @param {User} obj.user - user client - * @param {Secret[]} obj.secrets - secrets to validate against - * @param {String[]} requiredPermissions - required permissions as part of the endpoint - */ - const validateUserClientForSecrets = async ({ - user, - secrets, - requiredPermissions -}: { - user: IUser; - secrets: ISecret[]; - requiredPermissions?: string[]; -}) => { - - // TODO: add acceptedRoles? - - const userMemberships = await Membership.find({ user: user._id }) - const userMembershipById = _.keyBy(userMemberships, 'workspace'); - const workspaceIdsSet = new Set(userMemberships.map((m) => m.workspace.toString())); - - // for each secret check if the secret belongs to a workspace the user is a member of - secrets.forEach((secret: ISecret) => { - if (!workspaceIdsSet.has(secret.workspace.toString())) { - throw BadRequestError({ - message: 'Failed authorization for the secret' - }); - } - - if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { - const deniedMembershipPermissions = userMembershipById[secret.workspace.toString()].deniedPermissions; - const isDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: secret.environment, ability: PERMISSION_WRITE_SECRETS }); - - if (isDisallowed) { - throw UnauthorizedRequestError({ - message: 'You do not have the required permissions to perform this action' - }); - } - } - }); -} - -/** - * Validate that user (client) can access service account [serviceAccount] - * with required permissions [requiredPermissions] - * @param {Object} obj - * @param {User} obj.user - user client - * @param {ServiceAccount} obj.serviceAccount - service account to validate against - * @param {String[]} requiredPermissions - required permissions as part of the endpoint - */ -const validateUserClientForServiceAccount = async ({ - user, - serviceAccount, - requiredPermissions -}: { - user: IUser; - serviceAccount: IServiceAccount; - requiredPermissions?: string[]; -}) => { - if (!serviceAccount.user.equals(user._id)) { - // case: user who created service account is not the - // same user that is on the request - await validateMembershipOrg({ - userId: user._id, - organizationId: serviceAccount.organization, - acceptedRoles: [], - acceptedStatuses: [] - }); - } -} - -/** - * Validate that user (client) can access organization [organization] - * @param {Object} obj - * @param {User} obj.user - user client - * @param {Organization} obj.organization - organization to validate against - */ - const validateUserClientForOrganization = async ({ - user, - organization, - acceptedRoles, - acceptedStatuses -}: { - user: IUser; - organization: IOrganization; - acceptedRoles: Array<'owner' | 'admin' | 'member'>; - acceptedStatuses: Array<'invited' | 'accepted'>; -}) => { - const membershipOrg = await validateMembershipOrg({ - userId: user._id, - organizationId: organization._id, - acceptedRoles, - acceptedStatuses - }); - - return membershipOrg; -} - -export { - setupAccount, - completeAccount, - checkUserDevice, - validateUserClientForWorkspace, - validateUserClientForSecrets, - validateUserClientForServiceAccount, - validateUserClientForOrganization, - validateUserClientForSecret -}; +} \ No newline at end of file diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index 2c6a50e21..6bc880981 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -1,136 +1,14 @@ import * as Sentry from '@sentry/node'; -import crypto from 'crypto'; -import { Types } from 'mongoose'; import { Workspace, Bot, Membership, Key, - Secret, - User, - IUser, - ServiceAccountWorkspacePermission, - ServiceAccount, - IServiceAccount, - ServiceTokenData, - IServiceTokenData, - SecretBlindIndexData + Secret } from '../models'; import { createBot } from '../helpers/bot'; -import { validateUserClientForWorkspace } from '../helpers/user'; -import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; -import { validateServiceTokenDataClientForWorkspace } from '../helpers/serviceTokenData'; -import { validateMembership } from '../helpers/membership'; -import { UnauthorizedRequestError, WorkspaceNotFoundError } from '../utils/errors'; -import { - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY -} from '../variables'; -import { encryptSymmetric } from '../utils/crypto'; import { SecretService } from '../services'; -/** - * Validate authenticated clients for workspace with id [workspaceId] based - * on any known permissions. - * @param {Object} obj - * @param {Object} obj.authData - authenticated client details - * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against - * @param {String} obj.environment - (optional) environment in workspace to validate against - * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles - * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint - */ -const validateClientForWorkspace = async ({ - authData, - workspaceId, - environment, - acceptedRoles, - requiredPermissions, - requireBlindIndicesEnabled -}: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }; - workspaceId: Types.ObjectId; - environment?: string; - acceptedRoles: Array<'admin' | 'member'>; - requiredPermissions?: string[]; - requireBlindIndicesEnabled: boolean; -}) => { - - const workspace = await Workspace.findById(workspaceId); - - if (!workspace) throw WorkspaceNotFoundError({ - message: 'Failed to find workspace' - }); - - if (requireBlindIndicesEnabled) { - // case: blind indices are not enabled for secrets in this workspace - // (i.e. workspace was created before blind indices were introduced - // and no admin has enabled it) - - const secretBlindIndexData = await SecretBlindIndexData.exists({ - workspace: new Types.ObjectId(workspaceId) - }); - - if (!secretBlindIndexData) throw UnauthorizedRequestError({ - message: 'Failed workspace authorization due to blind indices not being enabled' - }); - } - - if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { - const membership = await validateUserClientForWorkspace({ - user: authData.authPayload, - workspaceId, - environment, - acceptedRoles, - requiredPermissions - }); - - return ({ membership, workspace }); - } - - if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { - await validateServiceAccountClientForWorkspace({ - serviceAccount: authData.authPayload, - workspaceId, - environment, - requiredPermissions - }); - - return {}; - } - - if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { - await validateServiceTokenDataClientForWorkspace({ - serviceTokenData: authData.authPayload, - workspaceId, - environment, - requiredPermissions - }); - - return {}; - } - - if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { - const membership = await validateUserClientForWorkspace({ - user: authData.authPayload, - workspaceId, - environment, - acceptedRoles, - requiredPermissions - }); - - return ({ membership, workspace }); - } - - throw UnauthorizedRequestError({ - message: 'Failed client authorization for workspace' - }); -} - /** * Create a workspace with name [name] in organization with id [organizationId] * and a bot for it. @@ -203,7 +81,6 @@ const deleteWorkspace = async ({ id }: { id: string }) => { }; export { - validateClientForWorkspace, createWorkspace, deleteWorkspace }; diff --git a/backend/src/index.ts b/backend/src/index.ts index 5906cd5a1..178baaacb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,17 +3,9 @@ dotenv.config(); import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; -import * as Sentry from '@sentry/node'; import { DatabaseService } from './services'; import { EELicenseService } from './ee/services'; import { setUpHealthEndpoint } from './services/health'; -import { initSmtp } from './services/smtp'; -import { TelemetryService } from './services'; -import { setTransporter } from './helpers/nodemailer'; -import { createTestUserForDevelopment } from './utils/addDevelopmentUser'; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { patchRouterParam } = require('./utils/patchAsyncRoutes'); - import cookieParser from 'cookie-parser'; import swaggerUi = require('swagger-ui-express'); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -72,30 +64,17 @@ import { getLogger } from './utils/logger'; import { RouteNotFoundError } from './utils/errors'; import { requestErrorHandler } from './middleware/requestErrorHandler'; import { - getMongoURL, getNodeEnv, getPort, - getSentryDSN, getSiteURL } from './config'; +import { setup } from './utils/setup'; const main = async () => { - TelemetryService.logTelemetryMessage(); - setTransporter(await initSmtp()); + await setup(); await EELicenseService.initGlobalFeatureSet(); - await DatabaseService.initDatabase(await getMongoURL()); - if ((await getNodeEnv()) !== 'test') { - Sentry.init({ - dsn: await getSentryDSN(), - tracesSampleRate: 1.0, - debug: await getNodeEnv() === 'production' ? false : true, - environment: await getNodeEnv() - }); - } - - patchRouterParam(); const app = express(); app.enable('trust proxy'); app.use(express.json()); @@ -137,8 +116,8 @@ const main = async () => { 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); // deprecated + app.use('/api/v1/secret', v1SecretRouter); // deprecate + app.use('/api/v1/service-token', v1ServiceTokenRouter); // deprecate app.use('/api/v1/password', v1PasswordRouter); app.use('/api/v1/stripe', v1StripeRouter); app.use('/api/v1/integration', v1IntegrationRouter); @@ -153,9 +132,9 @@ const main = async () => { 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); - app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route + app.use('/api/v2/secret', v2SecretRouter); // deprecate + app.use('/api/v2/secrets', v2SecretsRouter); // note: in the process of moving to v3/secrets + app.use('/api/v2/service-token', v2ServiceTokenDataRouter); app.use('/api/v2/service-accounts', v2ServiceAccountsRouter); // new app.use('/api/v2/api-key', v2APIKeyDataRouter); @@ -166,7 +145,7 @@ const main = async () => { // api docs app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)) - // Server status + // server status app.use('/api', healthCheck) //* Handle unrouted requests and respond with proper error message as well as status code @@ -181,7 +160,7 @@ const main = async () => { (await getLogger("backend-main")).info(`Server started listening at port ${await getPort()}`) }); - await createTestUserForDevelopment(); + // await createTestUserForDevelopment(); setUpHealthEndpoint(server); server.on('close', async () => { diff --git a/backend/src/interfaces/utils/crypto.ts b/backend/src/interfaces/utils/crypto.ts new file mode 100644 index 000000000..cc2c54e3b --- /dev/null +++ b/backend/src/interfaces/utils/crypto.ts @@ -0,0 +1,41 @@ +export interface IGenerateKeyPairOutput { + publicKey: string; + privateKey: string +} + +export interface IEncryptAsymmetricInput { + plaintext: string; + publicKey: string; + privateKey: string; +} + +export interface IEncryptAsymmetricOutput { + ciphertext: string; + nonce: string; +} + +export interface IDecryptAsymmetricInput { + ciphertext: string; + nonce: string; + publicKey: string; + privateKey: string; +} + +export interface IEncryptSymmetricInput { + plaintext: string; + key: string; +} + +export interface IEncryptSymmetricOutput { + ciphertext: string; + iv: string; + tag: string; +} + +export interface IDecryptSymmetricInput { + ciphertext: string; + iv: string; + tag: string; + key: string; +} + diff --git a/backend/src/interfaces/utils/index.ts b/backend/src/interfaces/utils/index.ts new file mode 100644 index 000000000..7d27c9105 --- /dev/null +++ b/backend/src/interfaces/utils/index.ts @@ -0,0 +1 @@ +export * from './crypto'; \ No newline at end of file diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index 86ebf416c..fce9099eb 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -7,9 +7,6 @@ import { getAuthAPIKeyPayload, getAuthSAAKPayload } from '../helpers/auth'; -import { - UnauthorizedRequestError -} from '../utils/errors'; import { IUser, IServiceAccount, @@ -48,6 +45,7 @@ const requireAuth = ({ // validate auth token against accepted auth modes [acceptedAuthModes] // and return token type [authTokenType] and value [authTokenValue] + const { authMode, authTokenValue } = validateAuthMode({ headers: req.headers, acceptedAuthModes diff --git a/backend/src/middleware/requireBotAuth.ts b/backend/src/middleware/requireBotAuth.ts index 089f570c8..2de8217da 100644 --- a/backend/src/middleware/requireBotAuth.ts +++ b/backend/src/middleware/requireBotAuth.ts @@ -1,9 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { Bot } from '../models'; -import { validateMembership } from '../helpers/membership'; -import { validateClientForBot } from '../helpers/bot'; -import { AccountNotFoundError } from '../utils/errors'; +import { validateClientForBot } from '../validation'; type req = 'params' | 'body' | 'query'; diff --git a/backend/src/middleware/requireIntegrationAuth.ts b/backend/src/middleware/requireIntegrationAuth.ts index bcde94f34..94d39a6c1 100644 --- a/backend/src/middleware/requireIntegrationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuth.ts @@ -1,10 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { Integration, IntegrationAuth } from '../models'; -import { IntegrationService } from '../services'; -import { validateMembership } from '../helpers/membership'; -import { validateClientForIntegration } from '../helpers/integration'; -import { IntegrationNotFoundError, UnauthorizedRequestError } from '../utils/errors'; +import { validateClientForIntegration } from '../validation'; /** * Validate if user on request is a member of workspace with proper roles associated diff --git a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts index 8619fe084..2ffa7e230 100644 --- a/backend/src/middleware/requireIntegrationAuthorizationAuth.ts +++ b/backend/src/middleware/requireIntegrationAuthorizationAuth.ts @@ -1,10 +1,6 @@ import { Types } from 'mongoose'; import { Request, Response, NextFunction } from 'express'; -import { IntegrationAuth, IWorkspace } from '../models'; -import { IntegrationService } from '../services'; -import { validateClientForIntegrationAuth } from '../helpers/integrationAuth'; -import { validateMembership } from '../helpers/membership'; -import { UnauthorizedRequestError } from '../utils/errors'; +import { validateClientForIntegrationAuth } from '../validation'; type req = 'params' | 'body' | 'query'; diff --git a/backend/src/middleware/requireMembershipAuth.ts b/backend/src/middleware/requireMembershipAuth.ts index 851230371..e03a6ea12 100644 --- a/backend/src/middleware/requireMembershipAuth.ts +++ b/backend/src/middleware/requireMembershipAuth.ts @@ -1,13 +1,6 @@ import { Types } from 'mongoose'; import { Request, Response, NextFunction } from 'express'; -import { UnauthorizedRequestError } from '../utils/errors'; -import { - Membership, -} from '../models'; -import { - validateClientForMembership, - validateMembership -} from '../helpers/membership'; +import { validateClientForMembership } from '../validation'; type req = 'params' | 'body' | 'query'; diff --git a/backend/src/middleware/requireMembershipOrgAuth.ts b/backend/src/middleware/requireMembershipOrgAuth.ts index b34c9c5e2..dda90cae8 100644 --- a/backend/src/middleware/requireMembershipOrgAuth.ts +++ b/backend/src/middleware/requireMembershipOrgAuth.ts @@ -1,16 +1,6 @@ import { Types } from 'mongoose'; import { Request, Response, NextFunction } from 'express'; -import { UnauthorizedRequestError } from '../utils/errors'; -import { - MembershipOrg -} from '../models'; -import { - validateClientForMembershipOrg, - validateMembershipOrg -} from '../helpers/membershipOrg'; - - -// TODO: transform +import { validateClientForMembershipOrg } from '../validation'; type req = 'params' | 'body' | 'query'; diff --git a/backend/src/middleware/requireOrganizationAuth.ts b/backend/src/middleware/requireOrganizationAuth.ts index f6d8eb8ce..5f7ef151d 100644 --- a/backend/src/middleware/requireOrganizationAuth.ts +++ b/backend/src/middleware/requireOrganizationAuth.ts @@ -1,9 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { IOrganization, MembershipOrg } from '../models'; -import { UnauthorizedRequestError, ValidationError } from '../utils/errors'; -import { validateMembershipOrg } from '../helpers/membershipOrg'; -import { validateClientForOrganization } from '../helpers/organization'; +import { validateClientForOrganization } from '../validation'; type req = 'params' | 'body' | 'query'; diff --git a/backend/src/middleware/requireSecretAuth.ts b/backend/src/middleware/requireSecretAuth.ts index 1462d67b0..4fda73a23 100644 --- a/backend/src/middleware/requireSecretAuth.ts +++ b/backend/src/middleware/requireSecretAuth.ts @@ -1,13 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { UnauthorizedRequestError, SecretNotFoundError } from '../utils/errors'; -import { Secret } from '../models'; -import { - validateMembership -} from '../helpers/membership'; -import { - validateClientForSecret -} from '../helpers/secrets'; +import { validateClientForSecret } from '../validation'; // note: used for old /v1/secret and /v2/secret routes. // newer /v2/secrets routes use [requireSecretsAuth] middleware with the exception diff --git a/backend/src/middleware/requireSecretsAuth.ts b/backend/src/middleware/requireSecretsAuth.ts index a076a3f1a..f25487b97 100644 --- a/backend/src/middleware/requireSecretsAuth.ts +++ b/backend/src/middleware/requireSecretsAuth.ts @@ -1,8 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { UnauthorizedRequestError } from '../utils/errors'; -import { Secret, Membership } from '../models'; -import { validateClientForSecrets } from '../helpers/secrets'; +import { validateClientForSecrets } from '../validation'; const requireSecretsAuth = ({ acceptedRoles, diff --git a/backend/src/middleware/requireServiceAccountAuth.ts b/backend/src/middleware/requireServiceAccountAuth.ts index 40861a737..da690c7bb 100644 --- a/backend/src/middleware/requireServiceAccountAuth.ts +++ b/backend/src/middleware/requireServiceAccountAuth.ts @@ -1,15 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { ServiceAccount } from '../models'; -import { - ServiceAccountNotFoundError -} from '../utils/errors'; -import { - validateMembershipOrg -} from '../helpers/membershipOrg'; -import { - validateClientForServiceAccount -} from '../helpers/serviceAccount'; +import { validateClientForServiceAccount } from '../validation'; type req = 'params' | 'body' | 'query'; diff --git a/backend/src/middleware/requireServiceTokenDataAuth.ts b/backend/src/middleware/requireServiceTokenDataAuth.ts index 7715991ba..be93fd799 100644 --- a/backend/src/middleware/requireServiceTokenDataAuth.ts +++ b/backend/src/middleware/requireServiceTokenDataAuth.ts @@ -1,9 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { ServiceToken, ServiceTokenData } from '../models'; -import { validateClientForServiceTokenData } from '../helpers/serviceTokenData'; -import { validateMembership } from '../helpers/membership'; -import { AccountNotFoundError, UnauthorizedRequestError } from '../utils/errors'; +import { validateClientForServiceTokenData } from '../validation'; type req = 'params' | 'body' | 'query'; diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 7e97b2e32..da517b998 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { validateClientForWorkspace } from '../helpers/workspace'; +import { validateClientForWorkspace } from '../validation'; type req = 'params' | 'body' | 'query'; diff --git a/backend/src/models/backupPrivateKey.ts b/backend/src/models/backupPrivateKey.ts index 70dcd6475..09bcbb588 100644 --- a/backend/src/models/backupPrivateKey.ts +++ b/backend/src/models/backupPrivateKey.ts @@ -1,4 +1,9 @@ import { Schema, model, Types } from 'mongoose'; +import { + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 +} from '../variables'; export interface IBackupPrivateKey { _id: Types.ObjectId; @@ -7,6 +12,8 @@ export interface IBackupPrivateKey { iv: string; tag: string; salt: string; + algorithm: string; + keyEncoding: 'base64' | 'utf8'; verifier: string; } @@ -32,6 +39,19 @@ const backupPrivateKeySchema = new Schema( select: false, required: true }, + algorithm: { // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true + }, + keyEncoding: { + type: String, + enum: [ + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 + ], + required: true + }, salt: { type: String, select: false, diff --git a/backend/src/models/bot.ts b/backend/src/models/bot.ts index c7e5a9abe..5755bfd8e 100644 --- a/backend/src/models/bot.ts +++ b/backend/src/models/bot.ts @@ -1,4 +1,10 @@ import { Schema, model, Types } from 'mongoose'; +import { + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_HEX, + ENCODING_SCHEME_BASE64 +} from '../variables'; export interface IBot { _id: Types.ObjectId; @@ -9,6 +15,8 @@ export interface IBot { encryptedPrivateKey: string; iv: string; tag: string; + algorithm: 'aes-256-gcm'; + keyEncoding: 'base64' | 'utf8'; } const botSchema = new Schema( @@ -45,6 +53,21 @@ const botSchema = new Schema( type: String, required: true, select: false + }, + algorithm: { // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true, + select: false + }, + keyEncoding: { + type: String, + enum: [ + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 + ], + required: true, + select: false } }, { diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index ead969fc7..affe811df 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -14,6 +14,9 @@ import { INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 } from "../variables"; export interface IIntegrationAuth extends Document { @@ -31,6 +34,8 @@ export interface IIntegrationAuth extends Document { accessCiphertext?: string; accessIV?: string; accessTag?: string; + algorithm?: 'aes-256-gcm'; + keyEncoding?: 'utf8' | 'base64'; accessExpiresAt?: Date; } @@ -109,6 +114,19 @@ const integrationAuthSchema = new Schema( type: Date, select: false, }, + algorithm: { // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true + }, + keyEncoding: { + type: String, + enum: [ + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 + ], + required: true + } }, { timestamps: true, diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index 7670124bf..39e362bf7 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -2,6 +2,9 @@ import { Schema, model, Types } from 'mongoose'; import { SECRET_SHARED, SECRET_PERSONAL, + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 } from '../variables'; import { ROOT_FOLDER_PATH } from '../utils/folder'; @@ -25,6 +28,8 @@ export interface ISecret { secretCommentIV?: string; secretCommentTag?: string; secretCommentHash?: string; + algorithm: 'aes-256-gcm'; + keyEncoding: 'utf8' | 'base64'; tags?: string[]; path?: string; folder?: Types.ObjectId; @@ -111,6 +116,19 @@ const secretSchema = new Schema( type: String, required: false }, + algorithm: { // the encryption algorithm used + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true + }, + keyEncoding: { + type: String, + enum: [ + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 + ], + required: true + }, // the full path to the secret in relation to folders path: { type: String, diff --git a/backend/src/models/secretBlindIndexData.ts b/backend/src/models/secretBlindIndexData.ts index 47649dde4..885faaff6 100644 --- a/backend/src/models/secretBlindIndexData.ts +++ b/backend/src/models/secretBlindIndexData.ts @@ -1,4 +1,9 @@ import { Schema, model, Types, Document } from 'mongoose'; +import { + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 +} from '../variables'; export interface ISecretBlindIndexData extends Document { _id: Types.ObjectId; @@ -6,6 +11,8 @@ export interface ISecretBlindIndexData extends Document { encryptedSaltCiphertext: string; saltIV: string; saltTag: string; + algorithm: 'aes-256-gcm'; + keyEncoding: 'base64' | 'utf8' } const secretBlindIndexDataSchema = new Schema( @@ -15,7 +22,7 @@ const secretBlindIndexDataSchema = new Schema( ref: 'Workspace', required: true }, - encryptedSaltCiphertext: { + encryptedSaltCiphertext: { // TODO: make these select: false type: String, required: true }, @@ -26,7 +33,23 @@ const secretBlindIndexDataSchema = new Schema( saltTag: { type: String, required: true + }, + algorithm: { + type: String, + enum: [ALGORITHM_AES_256_GCM], + required: true, + select: false + }, + keyEncoding: { + type: String, + enum: [ + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 + ], + required: true, + select: false } + } ); diff --git a/backend/src/routes/v2/secrets.ts b/backend/src/routes/v2/secrets.ts index ab8e40488..0104505a9 100644 --- a/backend/src/routes/v2/secrets.ts +++ b/backend/src/routes/v2/secrets.ts @@ -7,9 +7,9 @@ import { requireSecretsAuth, validateRequest } from '../../middleware'; +import { validateClientForSecrets } from '../../validation'; import { query, body } from 'express-validator'; import { secretsController } from '../../controllers/v2'; -import { validateClientForSecrets } from '../../helpers/secrets'; import { ADMIN, MEMBER, diff --git a/backend/src/services/SecretService.ts b/backend/src/services/SecretService.ts index c215431f1..5b5b73e5f 100644 --- a/backend/src/services/SecretService.ts +++ b/backend/src/services/SecretService.ts @@ -1,4 +1,3 @@ -// WIP import { Types } from 'mongoose'; import { ISecret @@ -11,7 +10,6 @@ import { DeleteSecretParams } from '../interfaces/services/SecretService'; import { - initSecretBlindIndexDataHelper, createSecretBlindIndexDataHelper, getSecretBlindIndexSaltHelper, generateSecretBlindIndexWithSaltHelper, @@ -24,16 +22,6 @@ import { } from '../helpers/secrets'; class SecretService { - - /** - * - * @param param0 h - * @returns - */ - - static async initSecretBlindIndexDataHelper() { - return await initSecretBlindIndexDataHelper(); - } /** * Create secret blind index data containing encrypted blind index salt diff --git a/backend/src/utils/crypto.ts b/backend/src/utils/crypto.ts deleted file mode 100644 index ec61b496e..000000000 --- a/backend/src/utils/crypto.ts +++ /dev/null @@ -1,139 +0,0 @@ -import nacl from 'tweetnacl'; -import util from 'tweetnacl-util'; -import AesGCM from './aes-gcm'; - -/** - * Return new base64, NaCl, public-private key pair. - * @returns {Object} obj - * @returns {String} obj.publicKey - base64, NaCl, public key - * @returns {String} obj.privateKey - base64, NaCl, private key - */ -const generateKeyPair = () => { - const pair = nacl.box.keyPair(); - - return ({ - publicKey: util.encodeBase64(pair.publicKey), - privateKey: util.encodeBase64(pair.secretKey) - }); -} - -/** - * Return assymmetrically encrypted [plaintext] using [publicKey] where - * [publicKey] likely belongs to the recipient. - * @param {Object} obj - * @param {String} obj.plaintext - plaintext to encrypt - * @param {String} obj.publicKey - public key of the recipient - * @param {String} obj.privateKey - private key of the sender (current user) - * @returns {Object} obj - * @returns {String} ciphertext - base64-encoded ciphertext - * @returns {String} nonce - base64-encoded nonce - */ -const encryptAsymmetric = ({ - plaintext, - publicKey, - privateKey -}: { - plaintext: string; - publicKey: string; - privateKey: string; -}) => { - const nonce = nacl.randomBytes(24); - const ciphertext = nacl.box( - util.decodeUTF8(plaintext), - nonce, - util.decodeBase64(publicKey), - util.decodeBase64(privateKey) - ); - - return { - ciphertext: util.encodeBase64(ciphertext), - nonce: util.encodeBase64(nonce) - }; -}; - -/** - * Return assymmetrically decrypted [ciphertext] using [privateKey] where - * [privateKey] likely belongs to the recipient. - * @param {Object} obj - * @param {String} obj.ciphertext - ciphertext to decrypt - * @param {String} obj.nonce - nonce - * @param {String} obj.publicKey - public key of the sender - * @param {String} obj.privateKey - private key of the receiver (current user) - * @param {String} plaintext - UTF8 plaintext - */ -const decryptAsymmetric = ({ - ciphertext, - nonce, - publicKey, - privateKey -}: { - ciphertext: string; - nonce: string; - publicKey: string; - privateKey: string; -}): string => { - const plaintext: any = nacl.box.open( - util.decodeBase64(ciphertext), - util.decodeBase64(nonce), - util.decodeBase64(publicKey), - util.decodeBase64(privateKey) - ); - - return util.encodeUTF8(plaintext); -}; - -/** - * Return symmetrically encrypted [plaintext] using [key]. - * @param {Object} obj - * @param {String} obj.plaintext - plaintext to encrypt - * @param {String} obj.key - hex key - */ -const encryptSymmetric = ({ - plaintext, - key -}: { - plaintext: string; - key: string; -}) => { - const obj = AesGCM.encrypt(plaintext, key); - const { ciphertext, iv, tag } = obj; - - return { - ciphertext, - iv, - tag - }; -}; - -/** - * Return symmetrically decypted [ciphertext] using [iv], [tag], - * and [key]. - * @param {Object} obj - * @param {String} obj.ciphertext - ciphertext to decrypt - * @param {String} obj.iv - iv - * @param {String} obj.tag - tag - * @param {String} obj.key - hex key - * - */ -const decryptSymmetric = ({ - ciphertext, - iv, - tag, - key -}: { - ciphertext: string; - iv: string; - tag: string; - key: string; -}): string => { - const plaintext = AesGCM.decrypt(ciphertext, iv, tag, key); - return plaintext; -}; - -export { - generateKeyPair, - encryptAsymmetric, - decryptAsymmetric, - encryptSymmetric, - decryptSymmetric -}; diff --git a/backend/src/utils/crypto/index.ts b/backend/src/utils/crypto/index.ts new file mode 100644 index 000000000..9c3877717 --- /dev/null +++ b/backend/src/utils/crypto/index.ts @@ -0,0 +1,166 @@ +import crypto from 'crypto'; +import nacl from 'tweetnacl'; +import util from 'tweetnacl-util'; +import { + IGenerateKeyPairOutput, + IEncryptAsymmetricInput, + IEncryptAsymmetricOutput, + IDecryptAsymmetricInput, + IEncryptSymmetricInput, + IDecryptSymmetricInput +} from '../../interfaces/utils'; +import { BadRequestError } from '../errors'; +import { + ALGORITHM_AES_256_GCM, + NONCE_BYTES_SIZE, + BLOCK_SIZE_BYTES_16 +} from '../../variables'; + +/** + * Return new base64, NaCl, public-private key pair. + * @returns {Object} obj + * @returns {String} obj.publicKey - (base64) NaCl, public key + * @returns {String} obj.privateKey - (base64), NaCl, private key + */ +const generateKeyPair = (): IGenerateKeyPairOutput => { + const pair = nacl.box.keyPair(); + + return ({ + publicKey: util.encodeBase64(pair.publicKey), + privateKey: util.encodeBase64(pair.secretKey) + }); +} + +/** + * Return assymmetrically encrypted [plaintext] using [publicKey] where + * [publicKey] likely belongs to the recipient. + * @param {Object} obj + * @param {String} obj.plaintext - plaintext to encrypt + * @param {String} obj.publicKey - (base64) Nacl public key of the recipient + * @param {String} obj.privateKey - (base64) Nacl private key of the sender (current user) + * @returns {Object} obj + * @returns {String} obj.ciphertext - (base64) ciphertext + * @returns {String} obj.nonce - (base64) nonce + */ +const encryptAsymmetric = ({ + plaintext, + publicKey, + privateKey +}: IEncryptAsymmetricInput): IEncryptAsymmetricOutput => { + const nonce = nacl.randomBytes(24); + const ciphertext = nacl.box( + util.decodeUTF8(plaintext), + nonce, + util.decodeBase64(publicKey), + util.decodeBase64(privateKey) + ); + + return { + ciphertext: util.encodeBase64(ciphertext), + nonce: util.encodeBase64(nonce) + }; +}; + +/** + * Return assymmetrically decrypted [ciphertext] using [privateKey] where + * [privateKey] likely belongs to the recipient. + * @param {Object} obj + * @param {String} obj.ciphertext - ciphertext to decrypt + * @param {String} obj.nonce - (base64) nonce + * @param {String} obj.publicKey - (base64) public key of the sender + * @param {String} obj.privateKey - (base64) private key of the receiver (current user) + * @returns {String} plaintext - (utf8) plaintext + */ +const decryptAsymmetric = ({ + ciphertext, + nonce, + publicKey, + privateKey +}: IDecryptAsymmetricInput): string => { + const plaintext: Uint8Array | null = nacl.box.open( + util.decodeBase64(ciphertext), + util.decodeBase64(nonce), + util.decodeBase64(publicKey), + util.decodeBase64(privateKey) + ); + + if (plaintext == null) throw BadRequestError({ + message: 'Invalid ciphertext or keys' + }); + + return util.encodeUTF8(plaintext); +}; + +/** + * Return symmetrically encrypted [plaintext] using [key]. + * + * NOTE: THIS FUNCTION SHOULD NOT BE USED FOR ALL FUTURE + * ENCRYPTION OPERATIONS UNLESS IT TOUCHES OLD FUNCTIONALITY + * THAT USES IT. USE encryptSymmetric() instead + * + * @param {Object} obj + * @param {String} obj.plaintext - (utf8) plaintext to encrypt + * @param {String} obj.key - (hex) 128-bit key + * @returns {Object} obj + * @returns {String} obj.ciphertext (base64) ciphertext + * @returns {String} obj.iv (base64) iv + * @returns {String} obj.tag (base64) tag + */ +const encryptSymmetric128BitHexKeyUTF8 = ({ + plaintext, + key +}: IEncryptSymmetricInput) => { + const iv = crypto.randomBytes(BLOCK_SIZE_BYTES_16); + const cipher = crypto.createCipheriv(ALGORITHM_AES_256_GCM, key, iv); + + let ciphertext = cipher.update(plaintext, 'utf8', 'base64'); + ciphertext += cipher.final('base64'); + + return { + ciphertext, + iv: iv.toString('base64'), + tag: cipher.getAuthTag().toString('base64') + }; +} +/** + * Return symmetrically decrypted [ciphertext] using [iv], [tag], + * and [key]. + * + * NOTE: THIS FUNCTION SHOULD NOT BE USED FOR ALL FUTURE + * DECRYPTION OPERATIONS UNLESS IT TOUCHES OLD FUNCTIONALITY + * THAT USES IT. USE decryptSymmetric() instead + * + * @param {Object} obj + * @param {String} obj.ciphertext - ciphertext to decrypt + * @param {String} obj.iv - (base64) 256-bit iv + * @param {String} obj.tag - (base64) tag + * @param {String} obj.key - (hex) 128-bit key + * @returns {String} cleartext - the deciphered ciphertext + */ +const decryptSymmetric128BitHexKeyUTF8 = ({ + ciphertext, + iv, + tag, + key +}: IDecryptSymmetricInput) => { + const decipher = crypto.createDecipheriv( + ALGORITHM_AES_256_GCM, + key, + Buffer.from(iv, 'base64') + ); + + decipher.setAuthTag(Buffer.from(tag, 'base64')); + + let cleartext = decipher.update(ciphertext, 'base64', 'utf8'); + cleartext += decipher.final('utf8'); + + return cleartext; +} + +export { + generateKeyPair, + encryptAsymmetric, + decryptAsymmetric, + encryptSymmetric128BitHexKeyUTF8, + decryptSymmetric128BitHexKeyUTF8 +}; diff --git a/backend/src/utils/setup/backfillData.ts b/backend/src/utils/setup/backfillData.ts new file mode 100644 index 000000000..cffb16476 --- /dev/null +++ b/backend/src/utils/setup/backfillData.ts @@ -0,0 +1,324 @@ +import crypto from 'crypto'; +import { encryptSymmetric128BitHexKeyUTF8 } from '../crypto'; +import { EESecretService } from '../../ee/services'; +import { SecretVersion } from '../../ee/models'; +import { + Secret, + ISecret, + SecretBlindIndexData, + Workspace, + Bot, + BackupPrivateKey, + IntegrationAuth, +} from '../../models'; +import { + generateKeyPair +} from '../../utils/crypto'; +import { + client, + getEncryptionKey, + getRootEncryptionKey +} from '../../config'; +import { + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 +} from '../../variables'; +import { InternalServerError } from '../errors'; + +/** + * Backfill secrets to ensure that they're all versioned and have + * corresponding secret versions + */ +export const backfillSecretVersions = async () => { + await Secret.updateMany( + { version: { $exists: false } }, + { $set: { version: 1 } } + ); + + const unversionedSecrets: ISecret[] = await Secret.aggregate([ + { + $lookup: { + from: "secretversions", + localField: "_id", + foreignField: "secret", + as: "versions", + }, + }, + { + $match: { + versions: { $size: 0 }, + }, + }, + ]); + + if (unversionedSecrets.length > 0) { + await EESecretService.addSecretVersions({ + secretVersions: unversionedSecrets.map( + (s, idx) => + new SecretVersion({ + ...s, + secret: s._id, + version: s.version ? s.version : 1, + isDeleted: false, + workspace: s.workspace, + environment: s.environment, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }) + ), + }); + } +} + +/** + * Backfill workspace bots to ensure that every workspace has a bot + */ +export const backfillBots = async () => { + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + + const workspaceIdsWithBot = await Bot.distinct('workspace'); + const workspaceIdsToAddBot = await Workspace.distinct('_id', { + _id: { + $nin: workspaceIdsWithBot + } + }); + + if (workspaceIdsToAddBot.length === 0) return; + + const botsToInsert = await Promise.all( + workspaceIdsToAddBot.map(async (workspaceToAddBot) => { + const { publicKey, privateKey } = generateKeyPair(); + + if (rootEncryptionKey) { + const { + ciphertext: encryptedPrivateKey, + iv, + tag + } = client.encryptSymmetric(privateKey, rootEncryptionKey); + + return new Bot({ + name: 'Infisical Bot', + workspace: workspaceToAddBot, + isActive: false, + publicKey, + encryptedPrivateKey, + iv, + tag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_BASE64 + }); + } else if (encryptionKey) { + const { + ciphertext: encryptedPrivateKey, + iv, + tag + } = encryptSymmetric128BitHexKeyUTF8({ + plaintext: privateKey, + key: encryptionKey + }); + + return new Bot({ + name: 'Infisical Bot', + workspace: workspaceToAddBot, + isActive: false, + publicKey, + encryptedPrivateKey, + iv, + tag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }); + } + + throw InternalServerError({ + message: 'Failed to backfill workspace bots due to missing encryption key' + }); + }) + ); + + await Bot.insertMany(botsToInsert); +} + +/** + * Backfill secret blind index data to ensure that every workspace + * has a secret blind index data + */ +export const backfillSecretBlindIndexData = async () => { + + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + + const workspaceIdsBlindIndexed = await SecretBlindIndexData.distinct('workspace'); + const workspaceIdsToBlindIndex = await Workspace.distinct('_id', { + _id: { + $nin: workspaceIdsBlindIndexed + } + }); + + if (workspaceIdsToBlindIndex.length === 0) return; + + const secretBlindIndexDataToInsert = await Promise.all( + workspaceIdsToBlindIndex.map(async (workspaceToBlindIndex) => { + const salt = crypto.randomBytes(16).toString('base64'); + + if (rootEncryptionKey) { + const { + ciphertext: encryptedSaltCiphertext, + iv: saltIV, + tag: saltTag + } = client.encryptSymmetric(salt, rootEncryptionKey) + + return new SecretBlindIndexData({ + workspace: workspaceToBlindIndex, + encryptedSaltCiphertext, + saltIV, + saltTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_BASE64 + }); + } else if (encryptionKey) { + const { + ciphertext: encryptedSaltCiphertext, + iv: saltIV, + tag: saltTag + } = encryptSymmetric128BitHexKeyUTF8({ + plaintext: salt, + key: encryptionKey + }); + + return new SecretBlindIndexData({ + workspace: workspaceToBlindIndex, + encryptedSaltCiphertext, + saltIV, + saltTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }); + } + + throw InternalServerError({ + message: 'Failed to backfill secret blind index data due to missing encryption key' + }); + }) + ); + + SecretBlindIndexData.insertMany(secretBlindIndexDataToInsert); +} + +/** + * Backfill Secret, SecretVersion, SecretBlindIndexData, Bot, + * BackupPrivateKey, IntegrationAuth collections to ensure that + * they all have encryption metadata documented + */ +export const backfillEncryptionMetadata = async () => { + + // backfill secret encryption metadata + await Secret.updateMany( + { + algorithm: { + $exists: false + }, + keyEncoding: { + $exists: false + } + }, + { + $set: { + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + } + } + ); + + // backfill secret version encryption metadata + await SecretVersion.updateMany( + { + algorithm: { + $exists: false + }, + keyEncoding: { + $exists: false + } + }, + { + $set: { + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + } + } + ); + + // backfill secret blind index encryption metadata + await SecretBlindIndexData.updateMany( + { + algorithm: { + $exists: false + }, + keyEncoding: { + $exists: false + } + }, + { + $set: { + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + } + } + ); + + // backfill bot encryption metadata + await Bot.updateMany( + { + algorithm: { + $exists: false + }, + keyEncoding: { + $exists: false + } + }, + { + $set: { + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + } + } + ); + + // backfill backup private key encryption metadata + await BackupPrivateKey.updateMany( + { + algorithm: { + $exists: false + }, + keyEncoding: { + $exists: false + } + }, + { + $set: { + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + } + } + ); + + // backfill integration auth encryption metadata + await IntegrationAuth.updateMany( + { + algorithm: { + $exists: false + }, + keyEncoding: { + $exists: false + } + }, + { + $set: { + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + } + } + ); +} \ No newline at end of file diff --git a/backend/src/utils/setup/index.ts b/backend/src/utils/setup/index.ts new file mode 100644 index 000000000..c59a974d0 --- /dev/null +++ b/backend/src/utils/setup/index.ts @@ -0,0 +1,78 @@ +import * as Sentry from '@sentry/node'; +import { DatabaseService, TelemetryService } from '../../services'; +import { setTransporter } from '../../helpers/nodemailer'; +import { EELicenseService } from '../../ee/services'; +import { initSmtp } from '../../services/smtp'; +import { createTestUserForDevelopment } from '../addDevelopmentUser' +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { patchRouterParam } = require('../patchAsyncRoutes'); +import { + validateEncryptionKeysConfig +} from './validateConfig'; +import { + backfillSecretVersions, + backfillBots, + backfillSecretBlindIndexData, + backfillEncryptionMetadata +} from './backfillData'; +import { + reencryptBotPrivateKeys, + reencryptSecretBlindIndexDataSalts +} from './reencryptData'; +import { + getNodeEnv, + getMongoURL, + getSentryDSN +} from '../../config'; + +/** + * Prepare Infisical upon startup. This includes tasks like: + * - Log initial telemetry message + * - Initializing SMTP configuration + * - Initializing the instance global feature set (if applicable) + * - Initializing the database connection + * - Initializing Sentry + * - Backfilling data + * - Re-encrypting data + */ +export const setup = async () => { + patchRouterParam(); + await validateEncryptionKeysConfig(); + await TelemetryService.logTelemetryMessage(); + + // initializing SMTP configuration + setTransporter(await initSmtp()); + + // initializing global feature set + await EELicenseService.initGlobalFeatureSet(); + + // initializing the database connection + await DatabaseService.initDatabase(await getMongoURL()); + + /** + * NOTE: the order in this setup function is critical. + * It is important to backfill data before performing any re-encryption functionality. + */ + + // backfilling data to catch up with new collections and updated fields + await backfillSecretVersions(); + await backfillBots(); + await backfillSecretBlindIndexData(); + await backfillEncryptionMetadata(); + + // re-encrypt any data previously encrypted under server hex 128-bit ENCRYPTION_KEY + // to base64 256-bit ROOT_ENCRYPTION_KEY + await reencryptBotPrivateKeys(); + await reencryptSecretBlindIndexDataSalts(); + + // initializing Sentry + Sentry.init({ + dsn: await getSentryDSN(), + tracesSampleRate: 1.0, + debug: (await getNodeEnv()) === 'production' ? false : true, + environment: (await getNodeEnv()) + }); + + await createTestUserForDevelopment(); +} + diff --git a/backend/src/utils/setup/reencryptData.ts b/backend/src/utils/setup/reencryptData.ts new file mode 100644 index 000000000..0741b44f4 --- /dev/null +++ b/backend/src/utils/setup/reencryptData.ts @@ -0,0 +1,124 @@ +import { + Bot, + IBot, + ISecretBlindIndexData, + SecretBlindIndexData +} from '../../models'; +import { decryptSymmetric128BitHexKeyUTF8 } from '../../utils/crypto'; +import { + client, + getEncryptionKey, + getRootEncryptionKey +} from '../../config'; +import { + ALGORITHM_AES_256_GCM, + ENCODING_SCHEME_UTF8, + ENCODING_SCHEME_BASE64 +} from '../../variables'; + +/** + * Re-encrypt bot private keys from hex 128-bit ENCRYPTION_KEY + * to base64 256-bit ROOT_ENCRYPTION_KEY + */ +export const reencryptBotPrivateKeys = async () => { + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + + if (encryptionKey && rootEncryptionKey) { + // 1: re-encrypt bot private keys under ROOT_ENCRYPTION_KEY + const bots = await Bot.find({ + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }).select('+encryptedPrivateKey iv tag algorithm keyEncoding'); + + if (bots.length === 0) return; + + const operationsBot = await Promise.all( + bots.map(async (bot: IBot) => { + + const privateKey = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: bot.encryptedPrivateKey, + iv: bot.iv, + tag: bot.tag, + key: encryptionKey + }); + + const { + ciphertext: encryptedPrivateKey, + iv, + tag + } = client.encryptSymmetric(privateKey, rootEncryptionKey); + + return ({ + updateOne: { + filter: { + _id: bot._id + }, + update: { + encryptedPrivateKey, + iv, + tag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_BASE64 + } + } + }) + }) + ); + + await Bot.bulkWrite(operationsBot); + } +} + +/** + * Re-encrypt secret blind index data salts from hex 128-bit ENCRYPTION_KEY + * to base64 256-bit ROOT_ENCRYPTION_KEY + */ +export const reencryptSecretBlindIndexDataSalts = async () => { + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + + if (encryptionKey && rootEncryptionKey) { + const secretBlindIndexData = await SecretBlindIndexData.find({ + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_UTF8 + }).select('+encryptedSaltCiphertext +saltIV +saltTag +algorithm +keyEncoding'); + + if (secretBlindIndexData.length == 0) return; + + const operationsSecretBlindIndexData = await Promise.all( + secretBlindIndexData.map(async (secretBlindIndexDatum: ISecretBlindIndexData) => { + + const salt = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secretBlindIndexDatum.encryptedSaltCiphertext, + iv: secretBlindIndexDatum.saltIV, + tag: secretBlindIndexDatum.saltTag, + key: encryptionKey + }); + + const { + ciphertext: encryptedSaltCiphertext, + iv: saltIV, + tag: saltTag + } = client.encryptSymmetric(salt, rootEncryptionKey); + + return ({ + updateOne: { + filter: { + _id: secretBlindIndexDatum._id + }, + update: { + encryptedSaltCiphertext, + saltIV, + saltTag, + algorithm: ALGORITHM_AES_256_GCM, + keyEncoding: ENCODING_SCHEME_BASE64 + } + } + }) + }) + ); + + await SecretBlindIndexData.bulkWrite(operationsSecretBlindIndexData); + } +} \ No newline at end of file diff --git a/backend/src/utils/setup/validateConfig.ts b/backend/src/utils/setup/validateConfig.ts new file mode 100644 index 000000000..5e7aa1e5b --- /dev/null +++ b/backend/src/utils/setup/validateConfig.ts @@ -0,0 +1,61 @@ +import { + getEncryptionKey, + getRootEncryptionKey +} from '../../config'; +import { + InternalServerError +} from '../../utils/errors'; + +/** + * Validate ENCRYPTION_KEY and ROOT_ENCRYPTION_KEY. Specifically: + * - ENCRYPTION_KEY is a hex, 128-bit string + * - ROOT_ENCRYPTION_KEY is a base64, 128-bit string + * - Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY are present + * + * - Encrypted data is consistent with the passed in encryption keys + * + * NOTE 1: ENCRYPTION_KEY is being transitioned to ROOT_ENCRYPTION_KEY + * NOTE 2: In the future, we will have a superior validation function + * built into the SDK. + */ +export const validateEncryptionKeysConfig = async () => { + const encryptionKey = await getEncryptionKey(); + const rootEncryptionKey = await getRootEncryptionKey(); + + if ( + (encryptionKey === undefined || encryptionKey === "") && + (rootEncryptionKey === undefined || rootEncryptionKey === "") + ) throw InternalServerError({ + message: "Failed to find required root encryption key environment variable. Please make sure that you're passing in a ROOT_ENCRYPTION_KEY environment variable." + }); + + if (encryptionKey && encryptionKey !== '') { + // validate [encryptionKey] + + const keyBuffer = Buffer.from(encryptionKey, 'hex'); + const decoded = keyBuffer.toString('hex'); + + if (decoded !== encryptionKey) throw InternalServerError({ + message: 'Failed to validate that the encryption key is correctly encoded in hex.' + }); + + if (keyBuffer.length !== 16) throw InternalServerError({ + message: 'Failed to validate that the encryption key is a 128-bit hex string.' + }); + } + + if (rootEncryptionKey && rootEncryptionKey !== '') { + // validate [rootEncryptionKey] + + const keyBuffer = Buffer.from(rootEncryptionKey, 'base64') + const decoded = keyBuffer.toString('base64'); + + if (decoded !== rootEncryptionKey) throw InternalServerError({ + message: 'Failed to validate that the root encryption key is correctly encoded in base64' + }); + + if (keyBuffer.length !== 32) throw InternalServerError({ + message: 'Failed to validate that the encryption key is a 256-bit base64 string' + }); + } +} \ No newline at end of file diff --git a/backend/src/validation/bot.ts b/backend/src/validation/bot.ts new file mode 100644 index 000000000..7104eec33 --- /dev/null +++ b/backend/src/validation/bot.ts @@ -0,0 +1,98 @@ +import { Types } from 'mongoose'; +import { + IUser, + IServiceAccount, + IServiceTokenData, + Bot, + User, + ServiceAccount, + ServiceTokenData +} from '../models'; +import { validateServiceAccountClientForWorkspace } from './serviceAccount'; +import { validateUserClientForWorkspace } from './user'; +import { + UnauthorizedRequestError, + BotNotFoundError +} from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate authenticated clients for bot with id [botId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.botId - id of bot to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + */ +export const validateClientForBot = async ({ + authData, + botId, + acceptedRoles, +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + botId: Types.ObjectId; + acceptedRoles: Array<"admin" | "member">; +}) => { + const bot = await Bot.findById(botId); + + if (!bot) throw BotNotFoundError(); + + if ( + authData.authMode === AUTH_MODE_JWT && + authData.authPayload instanceof User + ) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: bot.workspace, + acceptedRoles, + }); + + return bot; + } + + if ( + authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && + authData.authPayload instanceof ServiceAccount + ) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: bot.workspace, + }); + + return bot; + } + + if ( + authData.authMode === AUTH_MODE_SERVICE_TOKEN && + authData.authPayload instanceof ServiceTokenData + ) { + throw UnauthorizedRequestError({ + message: "Failed service token authorization for bot", + }); + } + + if ( + authData.authMode === AUTH_MODE_API_KEY && + authData.authPayload instanceof User + ) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: bot.workspace, + acceptedRoles, + }); + + return bot; + } + + throw BotNotFoundError({ + message: "Failed client authorization for bot", + }); +}; \ No newline at end of file diff --git a/backend/src/validation/index.ts b/backend/src/validation/index.ts new file mode 100644 index 000000000..84f25bb74 --- /dev/null +++ b/backend/src/validation/index.ts @@ -0,0 +1,10 @@ +export * from './workspace'; +export * from './bot'; +export * from './integration'; +export * from './integrationAuth'; +export * from './membership'; +export * from './membershipOrg'; +export * from './organization'; +export * from './secrets'; +export * from './serviceAccount'; +export * from './serviceTokenData'; \ No newline at end of file diff --git a/backend/src/validation/integration.ts b/backend/src/validation/integration.ts new file mode 100644 index 000000000..5b2f4ad3c --- /dev/null +++ b/backend/src/validation/integration.ts @@ -0,0 +1,103 @@ +import { Types } from 'mongoose'; +import { + IUser, + IServiceAccount, + IServiceTokenData, + Integration, + IntegrationAuth, + User, + ServiceAccount, + ServiceTokenData +} from '../models'; +import { validateServiceAccountClientForWorkspace } from './serviceAccount'; +import { validateUserClientForWorkspace } from './user'; +import { IntegrationService } from '../services'; +import { + IntegrationNotFoundError, + IntegrationAuthNotFoundError, + UnauthorizedRequestError +} from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate authenticated clients for integration with id [integrationId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.integrationId - id of integration to validate against + * @param {String} obj.environment - (optional) environment in workspace to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ +export const validateClientForIntegration = async ({ + authData, + integrationId, + acceptedRoles +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + integrationId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; +}) => { + + const integration = await Integration.findById(integrationId); + if (!integration) throw IntegrationNotFoundError(); + + const integrationAuth = await IntegrationAuth + .findById(integration.integrationAuth) + .select( + '+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt' + ); + + if (!integrationAuth) throw IntegrationAuthNotFoundError(); + + const accessToken = (await IntegrationService.getIntegrationAuthAccess({ + integrationAuthId: integrationAuth._id + })).accessToken; + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integration.workspace, + acceptedRoles + }); + + return ({ integration, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: integration.workspace + }); + + return ({ integration, accessToken }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service token authorization for integration' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: integration.workspace, + acceptedRoles + }); + + return ({ integration, accessToken }); + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for integration' + }); +} \ No newline at end of file diff --git a/backend/src/helpers/integrationAuth.ts b/backend/src/validation/integrationAuth.ts similarity index 95% rename from backend/src/helpers/integrationAuth.ts rename to backend/src/validation/integrationAuth.ts index c169fb799..b43dd4cbd 100644 --- a/backend/src/helpers/integrationAuth.ts +++ b/backend/src/validation/integrationAuth.ts @@ -20,8 +20,8 @@ import { UnauthorizedRequestError } from '../utils/errors'; import { IntegrationService } from '../services'; -import { validateUserClientForWorkspace } from '../helpers/user'; -import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; +import { validateUserClientForWorkspace } from './user'; +import { validateServiceAccountClientForWorkspace } from './serviceAccount'; /** * Validate authenticated clients for integration authorization with id [integrationAuthId] based diff --git a/backend/src/validation/membership.ts b/backend/src/validation/membership.ts new file mode 100644 index 000000000..ab4f8dc76 --- /dev/null +++ b/backend/src/validation/membership.ts @@ -0,0 +1,94 @@ +import { Types } from 'mongoose'; +import { + IUser, + IServiceAccount, + IServiceTokenData, + Membership, + User, + ServiceAccount, + ServiceTokenData +} from '../models'; +import { validateServiceAccountClientForWorkspace } from './serviceAccount'; +import { validateUserClientForWorkspace } from './user'; +import { validateServiceTokenDataClientForWorkspace } from './serviceTokenData'; +import { + MembershipNotFoundError, + UnauthorizedRequestError +} from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate authenticated clients for membership with id [membershipId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.membershipId - id of membership to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspaceRoles + * @returns {Membership} - validated membership + */ +export const validateClientForMembership = async ({ + authData, + membershipId, + acceptedRoles +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + membershipId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; +}) => { + + const membership = await Membership.findById(membershipId); + + if (!membership) throw MembershipNotFoundError({ + message: 'Failed to find membership' + }); + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: membership.workspace, + acceptedRoles + }); + + return membership; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: membership.workspace + }); + + return membership; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload, + workspaceId: new Types.ObjectId(membership.workspace) + }); + + return membership; + } + + if (authData.authMode == AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId: membership.workspace, + acceptedRoles + }); + + return membership; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for membership' + }); +} \ No newline at end of file diff --git a/backend/src/validation/membershipOrg.ts b/backend/src/validation/membershipOrg.ts new file mode 100644 index 000000000..7fd86a374 --- /dev/null +++ b/backend/src/validation/membershipOrg.ts @@ -0,0 +1,93 @@ +import { Types } from 'mongoose'; +import { + IUser, + IServiceAccount, + IServiceTokenData, + MembershipOrg, + User, + ServiceAccount, + ServiceTokenData +} from '../models'; +import { + validateMembershipOrg +} from '../helpers/membershipOrg'; +import { + MembershipOrgNotFoundError, + UnauthorizedRequestError +} from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate authenticated clients for organization membership with id [membershipOrgId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.membershipOrgId - id of organization membership to validate against + * @param {Array<'owner' | 'admin' | 'member'>} obj.acceptedRoles - accepted organization roles + * @param {MembershipOrg} - validated organization membership + */ +export const validateClientForMembershipOrg = async ({ + authData, + membershipOrgId, + acceptedRoles, + acceptedStatuses +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + membershipOrgId: Types.ObjectId; + acceptedRoles: Array<'owner' | 'admin' | 'member'>; + acceptedStatuses: Array<'invited' | 'accepted'>; +}) => { + const membershipOrg = await MembershipOrg.findById(membershipOrgId); + + if (!membershipOrg) throw MembershipOrgNotFoundError({ + message: 'Failed to find organization membership ' + }); + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateMembershipOrg({ + userId: authData.authPayload._id, + organizationId: membershipOrg.organization, + acceptedRoles, + acceptedStatuses + }); + + return membershipOrg; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + if (!authData.authPayload.organization.equals(membershipOrg.organization)) throw UnauthorizedRequestError({ + message: 'Failed service account client authorization for organization membership' + }); + + return membershipOrg; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + throw UnauthorizedRequestError({ + message: 'Failed service account client authorization for organization membership' + }); + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateMembershipOrg({ + userId: authData.authPayload._id, + organizationId: membershipOrg.organization, + acceptedRoles, + acceptedStatuses + }); + + return membershipOrg; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for organization membership' + }); +} \ No newline at end of file diff --git a/backend/src/validation/organization.ts b/backend/src/validation/organization.ts new file mode 100644 index 000000000..239517b75 --- /dev/null +++ b/backend/src/validation/organization.ts @@ -0,0 +1,104 @@ +import { Types } from 'mongoose'; +import { + IUser, + IServiceAccount, + IServiceTokenData, + Organization, + User, + ServiceAccount, + ServiceTokenData +} from '../models'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; +import { + OrganizationNotFoundError, + UnauthorizedRequestError +} from '../utils/errors'; +import { validateUserClientForOrganization } from './user'; +import { validateServiceAccountClientForOrganization } from './serviceAccount'; + +/** + * Validate accepted clients for organization with id [organizationId] + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.organizationId - id of organization to validate against + */ +export const validateClientForOrganization = async ({ + authData, + organizationId, + acceptedRoles, + acceptedStatuses, +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + organizationId: Types.ObjectId; + acceptedRoles: Array<"owner" | "admin" | "member">; + acceptedStatuses: Array<"invited" | "accepted">; +}) => { + const organization = await Organization.findById(organizationId); + + if (!organization) { + throw OrganizationNotFoundError({ + message: "Failed to find organization", + }); + } + + if ( + authData.authMode === AUTH_MODE_JWT && + authData.authPayload instanceof User + ) { + const membershipOrg = await validateUserClientForOrganization({ + user: authData.authPayload, + organization, + acceptedRoles, + acceptedStatuses, + }); + + return { organization, membershipOrg }; + } + + if ( + authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && + authData.authPayload instanceof ServiceAccount + ) { + await validateServiceAccountClientForOrganization({ + serviceAccount: authData.authPayload, + organization, + }); + + return { organization }; + } + + if ( + authData.authMode === AUTH_MODE_SERVICE_TOKEN && + authData.authPayload instanceof ServiceTokenData + ) { + throw UnauthorizedRequestError({ + message: "Failed service token authorization for organization", + }); + } + + if ( + authData.authMode === AUTH_MODE_API_KEY && + authData.authPayload instanceof User + ) { + const membershipOrg = await validateUserClientForOrganization({ + user: authData.authPayload, + organization, + acceptedRoles, + acceptedStatuses, + }); + + return { organization, membershipOrg }; + } + + throw UnauthorizedRequestError({ + message: "Failed client authorization for organization", + }); +}; \ No newline at end of file diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts new file mode 100644 index 000000000..272fe4545 --- /dev/null +++ b/backend/src/validation/secrets.ts @@ -0,0 +1,174 @@ +import { Types } from 'mongoose'; +import { + ISecret, + Secret, + User, + ServiceAccount, + ServiceTokenData +} from '../models'; +import { validateServiceAccountClientForWorkspace, validateServiceAccountClientForSecrets } from './serviceAccount'; +import { validateUserClientForSecret, validateUserClientForSecrets } from './user'; +import { validateServiceTokenDataClientForWorkspace, validateServiceTokenDataClientForSecrets } from './serviceTokenData'; +import { AuthData } from '../interfaces/middleware'; +import { + SecretNotFoundError, + UnauthorizedRequestError, + BadRequestError +} from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate authenticated clients for secrets with id [secretId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.secretId - id of secret to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ +export const validateClientForSecret = async ({ + authData, + secretId, + acceptedRoles, + requiredPermissions +}: { + authData: AuthData; + secretId: Types.ObjectId; + acceptedRoles: Array<'admin' | 'member'>; + requiredPermissions: string[]; +}) => { + const secret = await Secret.findById(secretId); + + if (!secret) throw SecretNotFoundError({ + message: 'Failed to find secret' + }); + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForSecret({ + user: authData.authPayload, + secret, + acceptedRoles, + requiredPermissions + }); + + return secret; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId: secret.workspace, + environment: secret.environment, + requiredPermissions + }); + + return secret; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload, + workspaceId: secret.workspace, + environment: secret.environment + }); + + return secret; + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForSecret({ + user: authData.authPayload, + secret, + acceptedRoles, + requiredPermissions + }); + + return secret; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for secret' + }); +} + +/** + * Validate authenticated clients for secrets with ids [secretIds] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId[]} obj.secretIds - id of workspace to validate against + * @param {String} obj.environment - (optional) environment in workspace to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ +export const validateClientForSecrets = async ({ + authData, + secretIds, + requiredPermissions +}: { + authData: AuthData; + secretIds: Types.ObjectId[]; + requiredPermissions: string[]; +}) => { + + let secrets: ISecret[] = []; + + secrets = await Secret.find({ + _id: { + $in: secretIds + } + }); + + if (secrets.length != secretIds.length) { + throw BadRequestError({ message: 'Failed to validate non-existent secrets' }) + } + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + await validateUserClientForSecrets({ + user: authData.authPayload, + secrets, + requiredPermissions + }); + + return secrets; + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForSecrets({ + serviceAccount: authData.authPayload, + secrets, + requiredPermissions + }); + + return secrets; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForSecrets({ + serviceTokenData: authData.authPayload, + secrets, + requiredPermissions + }); + + return secrets; + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + await validateUserClientForSecrets({ + user: authData.authPayload, + secrets, + requiredPermissions + }); + + return secrets; + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for secrets resource' + }); +} \ No newline at end of file diff --git a/backend/src/helpers/serviceAccount.ts b/backend/src/validation/serviceAccount.ts similarity index 92% rename from backend/src/helpers/serviceAccount.ts rename to backend/src/validation/serviceAccount.ts index 892767259..11997763c 100644 --- a/backend/src/helpers/serviceAccount.ts +++ b/backend/src/validation/serviceAccount.ts @@ -9,9 +9,9 @@ import { IServiceTokenData, ISecret, IOrganization, - IServiceAccountWorkspacePermission, ServiceAccountWorkspacePermission } from '../models'; +import { validateUserClientForServiceAccount } from './user'; import { BadRequestError, UnauthorizedRequestError, @@ -25,11 +25,8 @@ import { AUTH_MODE_SERVICE_TOKEN, AUTH_MODE_API_KEY } from '../variables'; -import { - validateUserClientForServiceAccount -} from '../helpers/user'; -const validateClientForServiceAccount = async ({ +export const validateClientForServiceAccount = async ({ authData, serviceAccountId, requiredPermissions @@ -100,7 +97,7 @@ const validateClientForServiceAccount = async ({ * @param {String} environment - (optional) environment in workspace to validate against * @param {String[]} requiredPermissions - required permissions as part of the endpoint */ - const validateServiceAccountClientForWorkspace = async ({ +export const validateServiceAccountClientForWorkspace = async ({ serviceAccount, workspaceId, environment, @@ -169,7 +166,7 @@ const validateClientForServiceAccount = async ({ * @param {Secret[]} secrets - secrets to validate against * @param {string[]} requiredPermissions - required permissions as part of the endpoint */ - const validateServiceAccountClientForSecrets = async ({ +export const validateServiceAccountClientForSecrets = async ({ serviceAccount, secrets, requiredPermissions @@ -226,7 +223,7 @@ const validateClientForServiceAccount = async ({ * @param {ServiceAccount} targetServiceAccount - target service account to validate against * @param {string[]} requiredPermissions - required permissions as part of the endpoint */ -const validateServiceAccountClientForServiceAccount = ({ +export const validateServiceAccountClientForServiceAccount = ({ serviceAccount, targetServiceAccount, requiredPermissions @@ -248,7 +245,7 @@ const validateServiceAccountClientForServiceAccount = ({ * @param {User} obj.user - service account client * @param {Organization} obj.organization - organization to validate against */ -const validateServiceAccountClientForOrganization = async ({ +export const validateServiceAccountClientForOrganization = async ({ serviceAccount, organization }: { @@ -260,12 +257,4 @@ const validateServiceAccountClientForOrganization = async ({ message: 'Failed service account authorization for the given organization' }); } -} - -export { - validateClientForServiceAccount, - validateServiceAccountClientForWorkspace, - validateServiceAccountClientForSecrets, - validateServiceAccountClientForServiceAccount, - validateServiceAccountClientForOrganization } \ No newline at end of file diff --git a/backend/src/helpers/serviceTokenData.ts b/backend/src/validation/serviceTokenData.ts similarity index 92% rename from backend/src/helpers/serviceTokenData.ts rename to backend/src/validation/serviceTokenData.ts index ecf46a4d1..8713e000f 100644 --- a/backend/src/helpers/serviceTokenData.ts +++ b/backend/src/validation/serviceTokenData.ts @@ -18,8 +18,8 @@ import { AUTH_MODE_SERVICE_TOKEN, AUTH_MODE_API_KEY } from '../variables'; -import { validateUserClientForWorkspace } from '../helpers/user'; -import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAccount'; +import { validateUserClientForWorkspace } from './user'; +import { validateServiceAccountClientForWorkspace } from './serviceAccount'; /** * Validate authenticated clients for service token with id [serviceTokenId] based @@ -29,7 +29,7 @@ import { validateServiceAccountClientForWorkspace } from '../helpers/serviceAcco * @param {Types.ObjectId} obj.serviceTokenData - id of service token to validate against * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles */ -const validateClientForServiceTokenData = async ({ +export const validateClientForServiceTokenData = async ({ authData, serviceTokenDataId, acceptedRoles @@ -100,7 +100,7 @@ const validateClientForServiceTokenData = async ({ * @param {String} environment - (optional) environment in workspace to validate against * @param {String[]} requiredPermissions - required permissions as part of the endpoint */ - const validateServiceTokenDataClientForWorkspace = async ({ +export const validateServiceTokenDataClientForWorkspace = async ({ serviceTokenData, workspaceId, environment, @@ -146,7 +146,7 @@ const validateClientForServiceTokenData = async ({ * @param {Secret[]} secrets - secrets to validate against * @param {string[]} requiredPermissions - required permissions as part of the endpoint */ - const validateServiceTokenDataClientForSecrets = async ({ +export const validateServiceTokenDataClientForSecrets = async ({ serviceTokenData, secrets, requiredPermissions @@ -179,10 +179,4 @@ const validateClientForServiceTokenData = async ({ } }); }); -} - -export { - validateClientForServiceTokenData, - validateServiceTokenDataClientForWorkspace, - validateServiceTokenDataClientForSecrets } \ No newline at end of file diff --git a/backend/src/validation/user.ts b/backend/src/validation/user.ts new file mode 100644 index 000000000..1329adfc4 --- /dev/null +++ b/backend/src/validation/user.ts @@ -0,0 +1,209 @@ +import { Types } from 'mongoose'; +import { + IUser, + ISecret, + IServiceAccount, + Membership, + IOrganization, +} from '../models'; +import { validateMembership } from '../helpers/membership'; +import _ from 'lodash'; +import { BadRequestError, UnauthorizedRequestError } from '../utils/errors'; +import { + validateMembershipOrg +} from '../helpers/membershipOrg'; +import { + PERMISSION_READ_SECRETS, + PERMISSION_WRITE_SECRETS +} from '../variables'; + +/** + * Validate that user (client) can access workspace + * with id [workspaceId] and its environment [environment] with required permissions + * [requiredPermissions] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against + * @param {String} environment - (optional) environment in workspace to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ +export const validateUserClientForWorkspace = async ({ + user, + workspaceId, + environment, + acceptedRoles, + requiredPermissions +}: { + user: IUser; + workspaceId: Types.ObjectId; + environment?: string; + acceptedRoles: Array<'admin' | 'member'>; + requiredPermissions?: string[]; +}) => { + + // validate user membership in workspace + const membership = await validateMembership({ + userId: user._id, + workspaceId, + acceptedRoles + }); + + let runningIsDisallowed = false; + requiredPermissions?.forEach((requiredPermission: string) => { + switch (requiredPermission) { + case PERMISSION_READ_SECRETS: + runningIsDisallowed = _.some(membership.deniedPermissions, { environmentSlug: environment, ability: PERMISSION_READ_SECRETS }); + break; + case PERMISSION_WRITE_SECRETS: + runningIsDisallowed = _.some(membership.deniedPermissions, { environmentSlug: environment, ability: PERMISSION_WRITE_SECRETS }); + break; + default: + break; + } + + if (runningIsDisallowed) { + throw UnauthorizedRequestError({ + message: `Failed permissions authorization for workspace environment action : ${requiredPermission}` + }); + } + }); + + return membership; +} + +/** + * Validate that user (client) can access secret [secret] + * with required permissions [requiredPermissions] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {Secret[]} obj.secrets - secrets to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ +export const validateUserClientForSecret = async ({ + user, + secret, + acceptedRoles, + requiredPermissions +}: { + user: IUser; + secret: ISecret; + acceptedRoles?: Array<'admin' | 'member'>; + requiredPermissions?: string[]; +}) => { + const membership = await validateMembership({ + userId: user._id, + workspaceId: secret.workspace, + acceptedRoles + }); + + if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { + const isDisallowed = _.some(membership.deniedPermissions, { environmentSlug: secret.environment, ability: PERMISSION_WRITE_SECRETS }); + + if (isDisallowed) { + throw UnauthorizedRequestError({ + message: 'You do not have the required permissions to perform this action' + }); + } + } +} + +/** + * Validate that user (client) can access secrets [secrets] + * with required permissions [requiredPermissions] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {Secret[]} obj.secrets - secrets to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ +export const validateUserClientForSecrets = async ({ + user, + secrets, + requiredPermissions +}: { + user: IUser; + secrets: ISecret[]; + requiredPermissions?: string[]; +}) => { + + // TODO: add acceptedRoles? + + const userMemberships = await Membership.find({ user: user._id }) + const userMembershipById = _.keyBy(userMemberships, 'workspace'); + const workspaceIdsSet = new Set(userMemberships.map((m) => m.workspace.toString())); + + // for each secret check if the secret belongs to a workspace the user is a member of + secrets.forEach((secret: ISecret) => { + if (!workspaceIdsSet.has(secret.workspace.toString())) { + throw BadRequestError({ + message: 'Failed authorization for the secret' + }); + } + + if (requiredPermissions?.includes(PERMISSION_WRITE_SECRETS)) { + const deniedMembershipPermissions = userMembershipById[secret.workspace.toString()].deniedPermissions; + const isDisallowed = _.some(deniedMembershipPermissions, { environmentSlug: secret.environment, ability: PERMISSION_WRITE_SECRETS }); + + if (isDisallowed) { + throw UnauthorizedRequestError({ + message: 'You do not have the required permissions to perform this action' + }); + } + } + }); +} + +/** + * Validate that user (client) can access service account [serviceAccount] + * with required permissions [requiredPermissions] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {ServiceAccount} obj.serviceAccount - service account to validate against + * @param {String[]} requiredPermissions - required permissions as part of the endpoint + */ +export const validateUserClientForServiceAccount = async ({ + user, + serviceAccount, + requiredPermissions +}: { + user: IUser; + serviceAccount: IServiceAccount; + requiredPermissions?: string[]; +}) => { + if (!serviceAccount.user.equals(user._id)) { + // case: user who created service account is not the + // same user that is on the request + await validateMembershipOrg({ + userId: user._id, + organizationId: serviceAccount.organization, + acceptedRoles: [], + acceptedStatuses: [] + }); + } +} + +/** + * Validate that user (client) can access organization [organization] + * @param {Object} obj + * @param {User} obj.user - user client + * @param {Organization} obj.organization - organization to validate against + */ +export const validateUserClientForOrganization = async ({ + user, + organization, + acceptedRoles, + acceptedStatuses +}: { + user: IUser; + organization: IOrganization; + acceptedRoles: Array<'owner' | 'admin' | 'member'>; + acceptedStatuses: Array<'invited' | 'accepted'>; +}) => { + const membershipOrg = await validateMembershipOrg({ + userId: user._id, + organizationId: organization._id, + acceptedRoles, + acceptedStatuses + }); + + return membershipOrg; +} \ No newline at end of file diff --git a/backend/src/validation/workspace.ts b/backend/src/validation/workspace.ts new file mode 100644 index 000000000..1cbdca420 --- /dev/null +++ b/backend/src/validation/workspace.ts @@ -0,0 +1,124 @@ +import { Types } from 'mongoose'; +import { + IUser, + IServiceAccount, + IServiceTokenData, + Workspace, + User, + ServiceAccount, + ServiceTokenData, + SecretBlindIndexData +} from '../models'; +import { validateServiceAccountClientForWorkspace } from './serviceAccount'; +import { validateUserClientForWorkspace } from './user'; +import { validateServiceTokenDataClientForWorkspace } from './serviceTokenData'; +import { + UnauthorizedRequestError, + WorkspaceNotFoundError +} from '../utils/errors'; +import { + AUTH_MODE_JWT, + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_API_KEY +} from '../variables'; + +/** + * Validate authenticated clients for workspace with id [workspaceId] based + * on any known permissions. + * @param {Object} obj + * @param {Object} obj.authData - authenticated client details + * @param {Types.ObjectId} obj.workspaceId - id of workspace to validate against + * @param {String} obj.environment - (optional) environment in workspace to validate against + * @param {Array<'admin' | 'member'>} obj.acceptedRoles - accepted workspace roles + * @param {String[]} obj.requiredPermissions - required permissions as part of the endpoint + */ +export const validateClientForWorkspace = async ({ + authData, + workspaceId, + environment, + acceptedRoles, + requiredPermissions, + requireBlindIndicesEnabled +}: { + authData: { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + }; + workspaceId: Types.ObjectId; + environment?: string; + acceptedRoles: Array<'admin' | 'member'>; + requiredPermissions?: string[]; + requireBlindIndicesEnabled: boolean; +}) => { + + const workspace = await Workspace.findById(workspaceId); + + if (!workspace) throw WorkspaceNotFoundError({ + message: 'Failed to find workspace' + }); + + if (requireBlindIndicesEnabled) { + // case: blind indices are not enabled for secrets in this workspace + // (i.e. workspace was created before blind indices were introduced + // and no admin has enabled it) + + const secretBlindIndexData = await SecretBlindIndexData.exists({ + workspace: new Types.ObjectId(workspaceId) + }); + + if (!secretBlindIndexData) throw UnauthorizedRequestError({ + message: 'Failed workspace authorization due to blind indices not being enabled' + }); + } + + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { + const membership = await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId, + environment, + acceptedRoles, + requiredPermissions + }); + + return ({ membership, workspace }); + } + + if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { + await validateServiceAccountClientForWorkspace({ + serviceAccount: authData.authPayload, + workspaceId, + environment, + requiredPermissions + }); + + return {}; + } + + if (authData.authMode === AUTH_MODE_SERVICE_TOKEN && authData.authPayload instanceof ServiceTokenData) { + await validateServiceTokenDataClientForWorkspace({ + serviceTokenData: authData.authPayload, + workspaceId, + environment, + requiredPermissions + }); + + return {}; + } + + if (authData.authMode === AUTH_MODE_API_KEY && authData.authPayload instanceof User) { + const membership = await validateUserClientForWorkspace({ + user: authData.authPayload, + workspaceId, + environment, + acceptedRoles, + requiredPermissions + }); + + return ({ membership, workspace }); + } + + throw UnauthorizedRequestError({ + message: 'Failed client authorization for workspace' + }); +} diff --git a/backend/src/variables/action.ts b/backend/src/variables/action.ts index 682b357a7..9d4fb18d9 100644 --- a/backend/src/variables/action.ts +++ b/backend/src/variables/action.ts @@ -1,15 +1,6 @@ -const ACTION_LOGIN = 'login'; -const ACTION_LOGOUT = 'logout'; -const ACTION_ADD_SECRETS = 'addSecrets'; -const ACTION_DELETE_SECRETS = 'deleteSecrets'; -const ACTION_UPDATE_SECRETS = 'updateSecrets'; -const ACTION_READ_SECRETS = 'readSecrets'; - -export { - ACTION_LOGIN, - ACTION_LOGOUT, - ACTION_ADD_SECRETS, - ACTION_DELETE_SECRETS, - ACTION_UPDATE_SECRETS, - ACTION_READ_SECRETS -} \ No newline at end of file +export const ACTION_LOGIN = 'login'; +export const ACTION_LOGOUT = 'logout'; +export const ACTION_ADD_SECRETS = 'addSecrets'; +export const ACTION_DELETE_SECRETS = 'deleteSecrets'; +export const ACTION_UPDATE_SECRETS = 'updateSecrets'; +export const ACTION_READ_SECRETS = 'readSecrets'; \ No newline at end of file diff --git a/backend/src/variables/authentication.ts b/backend/src/variables/authentication.ts index 2fe1f4fc6..ac19d2756 100644 --- a/backend/src/variables/authentication.ts +++ b/backend/src/variables/authentication.ts @@ -1,11 +1,4 @@ -const AUTH_MODE_JWT = 'jwt'; -const AUTH_MODE_SERVICE_ACCOUNT = 'serviceAccount'; -const AUTH_MODE_SERVICE_TOKEN = 'serviceToken'; -const AUTH_MODE_API_KEY = 'apiKey'; // TODO: deprecate - -export { - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY -} \ No newline at end of file +export const AUTH_MODE_JWT = 'jwt'; +export const AUTH_MODE_SERVICE_ACCOUNT = 'serviceAccount'; +export const AUTH_MODE_SERVICE_TOKEN = 'serviceToken'; +export const AUTH_MODE_API_KEY = 'apiKey'; // TODO: deprecate \ No newline at end of file diff --git a/backend/src/variables/crypto.ts b/backend/src/variables/crypto.ts new file mode 100644 index 000000000..bd2ae110c --- /dev/null +++ b/backend/src/variables/crypto.ts @@ -0,0 +1,7 @@ +export const ALGORITHM_AES_256_GCM = 'aes-256-gcm'; +export const NONCE_BYTES_SIZE = 12; +export const BLOCK_SIZE_BYTES_16 = 16; + +export const ENCODING_SCHEME_UTF8 = 'utf8'; +export const ENCODING_SCHEME_HEX = 'hex'; +export const ENCODING_SCHEME_BASE64 = 'base64'; \ No newline at end of file diff --git a/backend/src/variables/environment.ts b/backend/src/variables/environment.ts index 44d7cdbb2..b068b3d36 100644 --- a/backend/src/variables/environment.ts +++ b/backend/src/variables/environment.ts @@ -1,14 +1,6 @@ // environments -const ENV_DEV = 'dev'; -const ENV_TESTING = 'test'; -const ENV_STAGING = 'staging'; -const ENV_PROD = 'prod'; -const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]); - -export { - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, - ENV_SET -} \ No newline at end of file +export const ENV_DEV = 'dev'; +export const ENV_TESTING = 'test'; +export const ENV_STAGING = 'staging'; +export const ENV_PROD = 'prod'; +export const ENV_SET = new Set([ENV_DEV, ENV_TESTING, ENV_STAGING, ENV_PROD]); \ No newline at end of file diff --git a/backend/src/variables/event.ts b/backend/src/variables/event.ts index 4477e8e02..1c55eb3ec 100644 --- a/backend/src/variables/event.ts +++ b/backend/src/variables/event.ts @@ -1,7 +1,2 @@ -const EVENT_PUSH_SECRETS = 'pushSecrets'; -const EVENT_PULL_SECRETS = 'pullSecrets'; - -export { - EVENT_PUSH_SECRETS, - EVENT_PULL_SECRETS -} \ No newline at end of file +export const EVENT_PUSH_SECRETS = 'pushSecrets'; +export const EVENT_PULL_SECRETS = 'pullSecrets'; \ No newline at end of file diff --git a/backend/src/variables/index.ts b/backend/src/variables/index.ts index 450c2fedd..281145a99 100644 --- a/backend/src/variables/index.ts +++ b/backend/src/variables/index.ts @@ -1,154 +1,13 @@ -import { - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, - ENV_SET, -} from "./environment"; -import { - INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_AWS_PARAMETER_STORE, - INTEGRATION_AWS_SECRET_MANAGER, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_GITLAB, - INTEGRATION_RENDER, - INTEGRATION_RAILWAY, - INTEGRATION_FLYIO, - INTEGRATION_CIRCLECI, - INTEGRATION_TRAVISCI, - INTEGRATION_SUPABASE, - INTEGRATION_SET, - INTEGRATION_OAUTH2, - INTEGRATION_AZURE_TOKEN_URL, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_GITHUB_TOKEN_URL, - INTEGRATION_GITLAB_TOKEN_URL, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_GITLAB_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, - INTEGRATION_RENDER_API_URL, - INTEGRATION_RAILWAY_API_URL, - INTEGRATION_FLYIO_API_URL, - INTEGRATION_CIRCLECI_API_URL, - INTEGRATION_TRAVISCI_API_URL, - INTEGRATION_SUPABASE_API_URL, - getIntegrationOptions -} from "./integration"; -import { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED } from "./organization"; -import { SECRET_SHARED, SECRET_PERSONAL } from "./secret"; -import { EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS } from "./event"; -import { - ACTION_LOGIN, - ACTION_LOGOUT, - ACTION_ADD_SECRETS, - ACTION_UPDATE_SECRETS, - ACTION_DELETE_SECRETS, - ACTION_READ_SECRETS -} from './action'; -import { - SMTP_HOST_SENDGRID, - SMTP_HOST_MAILGUN, - SMTP_HOST_SOCKETLABS, - SMTP_HOST_ZOHOMAIL, - SMTP_HOST_GMAIL -} from './smtp'; -import { PLAN_STARTER, PLAN_PRO } from './stripe'; -import { - MFA_METHOD_EMAIL -} from './user'; -import { - TOKEN_EMAIL_CONFIRMATION, - TOKEN_EMAIL_MFA, - TOKEN_EMAIL_ORG_INVITATION, - TOKEN_EMAIL_PASSWORD_RESET -} from './token'; -import { - PERMISSION_READ_SECRETS, - PERMISSION_WRITE_SECRETS -} from './permission'; -import { - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY -} from './authentication'; - -export { - OWNER, - ADMIN, - MEMBER, - INVITED, - ACCEPTED, - SECRET_SHARED, - SECRET_PERSONAL, - ENV_DEV, - ENV_TESTING, - ENV_STAGING, - ENV_PROD, - ENV_SET, - INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_AWS_PARAMETER_STORE, - INTEGRATION_AWS_SECRET_MANAGER, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_GITLAB, - INTEGRATION_RENDER, - INTEGRATION_RAILWAY, - INTEGRATION_FLYIO, - INTEGRATION_CIRCLECI, - INTEGRATION_TRAVISCI, - INTEGRATION_SUPABASE, - INTEGRATION_SET, - INTEGRATION_OAUTH2, - INTEGRATION_AZURE_TOKEN_URL, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_GITHUB_TOKEN_URL, - INTEGRATION_GITLAB_TOKEN_URL, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_GITLAB_API_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, - INTEGRATION_RENDER_API_URL, - INTEGRATION_RAILWAY_API_URL, - INTEGRATION_FLYIO_API_URL, - INTEGRATION_CIRCLECI_API_URL, - INTEGRATION_TRAVISCI_API_URL, - INTEGRATION_SUPABASE_API_URL, - EVENT_PUSH_SECRETS, - EVENT_PULL_SECRETS, - ACTION_LOGIN, - ACTION_LOGOUT, - ACTION_ADD_SECRETS, - ACTION_UPDATE_SECRETS, - ACTION_DELETE_SECRETS, - ACTION_READ_SECRETS, - PERMISSION_READ_SECRETS, - PERMISSION_WRITE_SECRETS, - getIntegrationOptions, - SMTP_HOST_SENDGRID, - SMTP_HOST_MAILGUN, - SMTP_HOST_SOCKETLABS, - SMTP_HOST_ZOHOMAIL, - SMTP_HOST_GMAIL, - PLAN_STARTER, - PLAN_PRO, - MFA_METHOD_EMAIL, - TOKEN_EMAIL_CONFIRMATION, - TOKEN_EMAIL_MFA, - TOKEN_EMAIL_ORG_INVITATION, - TOKEN_EMAIL_PASSWORD_RESET, - AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT, - AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY -}; +export * from './action'; +export * from './authentication'; +export * from './crypto'; +export * from './environment'; +export * from './event'; +export * from './integration'; +export * from './organization'; +export * from './permission'; +export * from './secret'; +export * from './smtp'; +export * from './stripe'; +export * from './token'; +export * from './user'; diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index aac78968d..6efd96817 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -8,21 +8,21 @@ import { } from '../config'; // integrations -const INTEGRATION_AZURE_KEY_VAULT = 'azure-key-vault'; -const INTEGRATION_AWS_PARAMETER_STORE = 'aws-parameter-store'; -const INTEGRATION_AWS_SECRET_MANAGER = 'aws-secret-manager'; -const INTEGRATION_HEROKU = "heroku"; -const INTEGRATION_VERCEL = "vercel"; -const INTEGRATION_NETLIFY = "netlify"; -const INTEGRATION_GITHUB = "github"; -const INTEGRATION_GITLAB = "gitlab"; -const INTEGRATION_RENDER = "render"; -const INTEGRATION_RAILWAY = "railway"; -const INTEGRATION_FLYIO = "flyio"; -const INTEGRATION_CIRCLECI = "circleci"; -const INTEGRATION_TRAVISCI = "travisci"; -const INTEGRATION_SUPABASE = 'supabase'; -const INTEGRATION_SET = new Set([ +export const INTEGRATION_AZURE_KEY_VAULT = 'azure-key-vault'; +export const INTEGRATION_AWS_PARAMETER_STORE = 'aws-parameter-store'; +export const INTEGRATION_AWS_SECRET_MANAGER = 'aws-secret-manager'; +export const INTEGRATION_HEROKU = "heroku"; +export const INTEGRATION_VERCEL = "vercel"; +export const INTEGRATION_NETLIFY = "netlify"; +export const INTEGRATION_GITHUB = "github"; +export const INTEGRATION_GITLAB = "gitlab"; +export const INTEGRATION_RENDER = "render"; +export const INTEGRATION_RAILWAY = "railway"; +export const INTEGRATION_FLYIO = "flyio"; +export const INTEGRATION_CIRCLECI = "circleci"; +export const INTEGRATION_TRAVISCI = "travisci"; +export const INTEGRATION_SUPABASE = 'supabase'; +export const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, INTEGRATION_VERCEL, @@ -37,31 +37,31 @@ const INTEGRATION_SET = new Set([ ]); // integration types -const INTEGRATION_OAUTH2 = "oauth2"; +export const INTEGRATION_OAUTH2 = "oauth2"; // integration oauth endpoints -const INTEGRATION_AZURE_TOKEN_URL = `https://login.microsoftonline.com/common/oauth2/v2.0/token`; -const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; -const INTEGRATION_VERCEL_TOKEN_URL = +export const INTEGRATION_AZURE_TOKEN_URL = `https://login.microsoftonline.com/common/oauth2/v2.0/token`; +export const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token'; +export const INTEGRATION_VERCEL_TOKEN_URL = "https://api.vercel.com/v2/oauth/access_token"; -const INTEGRATION_NETLIFY_TOKEN_URL = "https://api.netlify.com/oauth/token"; -const INTEGRATION_GITHUB_TOKEN_URL = +export const INTEGRATION_NETLIFY_TOKEN_URL = "https://api.netlify.com/oauth/token"; +export const INTEGRATION_GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; -const INTEGRATION_GITLAB_TOKEN_URL = "https://gitlab.com/oauth/token"; +export const INTEGRATION_GITLAB_TOKEN_URL = "https://gitlab.com/oauth/token"; // integration apps endpoints -const INTEGRATION_HEROKU_API_URL = "https://api.heroku.com"; -const INTEGRATION_GITLAB_API_URL = "https://gitlab.com/api"; -const INTEGRATION_VERCEL_API_URL = "https://api.vercel.com"; -const INTEGRATION_NETLIFY_API_URL = "https://api.netlify.com"; -const INTEGRATION_RENDER_API_URL = "https://api.render.com"; -const INTEGRATION_RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2"; -const INTEGRATION_FLYIO_API_URL = "https://api.fly.io/graphql"; -const INTEGRATION_CIRCLECI_API_URL = "https://circleci.com/api"; -const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; -const INTEGRATION_SUPABASE_API_URL = 'https://api.supabase.com'; +export const INTEGRATION_HEROKU_API_URL = "https://api.heroku.com"; +export const INTEGRATION_GITLAB_API_URL = "https://gitlab.com/api"; +export const INTEGRATION_VERCEL_API_URL = "https://api.vercel.com"; +export const INTEGRATION_NETLIFY_API_URL = "https://api.netlify.com"; +export const INTEGRATION_RENDER_API_URL = "https://api.render.com"; +export const INTEGRATION_RAILWAY_API_URL = "https://backboard.railway.app/graphql/v2"; +export const INTEGRATION_FLYIO_API_URL = "https://api.fly.io/graphql"; +export const INTEGRATION_CIRCLECI_API_URL = "https://circleci.com/api"; +export const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; +export const INTEGRATION_SUPABASE_API_URL = 'https://api.supabase.com'; -const getIntegrationOptions = async () => { +export const getIntegrationOptions = async () => { const INTEGRATION_OPTIONS = [ { name: 'Heroku', @@ -202,41 +202,4 @@ const getIntegrationOptions = async () => { ] return INTEGRATION_OPTIONS; -} - - -export { - INTEGRATION_AZURE_KEY_VAULT, - INTEGRATION_AWS_PARAMETER_STORE, - INTEGRATION_AWS_SECRET_MANAGER, - INTEGRATION_HEROKU, - INTEGRATION_VERCEL, - INTEGRATION_NETLIFY, - INTEGRATION_GITHUB, - INTEGRATION_GITLAB, - INTEGRATION_RENDER, - INTEGRATION_RAILWAY, - INTEGRATION_FLYIO, - INTEGRATION_CIRCLECI, - INTEGRATION_TRAVISCI, - INTEGRATION_SUPABASE, - INTEGRATION_SET, - INTEGRATION_OAUTH2, - INTEGRATION_AZURE_TOKEN_URL, - INTEGRATION_HEROKU_TOKEN_URL, - INTEGRATION_VERCEL_TOKEN_URL, - INTEGRATION_NETLIFY_TOKEN_URL, - INTEGRATION_GITHUB_TOKEN_URL, - INTEGRATION_GITLAB_API_URL, - INTEGRATION_HEROKU_API_URL, - INTEGRATION_GITLAB_TOKEN_URL, - INTEGRATION_VERCEL_API_URL, - INTEGRATION_NETLIFY_API_URL, - INTEGRATION_RENDER_API_URL, - INTEGRATION_RAILWAY_API_URL, - INTEGRATION_FLYIO_API_URL, - INTEGRATION_CIRCLECI_API_URL, - INTEGRATION_TRAVISCI_API_URL, - INTEGRATION_SUPABASE_API_URL, - getIntegrationOptions -}; +} \ No newline at end of file diff --git a/backend/src/variables/organization.ts b/backend/src/variables/organization.ts index 80c7102c1..4f5620236 100644 --- a/backend/src/variables/organization.ts +++ b/backend/src/variables/organization.ts @@ -1,12 +1,10 @@ // membership roles -const OWNER = "owner"; -const ADMIN = "admin"; -const MEMBER = "member"; +export const OWNER = "owner"; +export const ADMIN = "admin"; +export const MEMBER = "member"; // membership statuses -const INVITED = "invited"; +export const INVITED = "invited"; // -- organization -const ACCEPTED = "accepted"; - -export { OWNER, ADMIN, MEMBER, INVITED, ACCEPTED }; +export const ACCEPTED = "accepted"; \ No newline at end of file diff --git a/backend/src/variables/permission.ts b/backend/src/variables/permission.ts index 769344d7f..98c9ef538 100644 --- a/backend/src/variables/permission.ts +++ b/backend/src/variables/permission.ts @@ -1,7 +1,2 @@ -const PERMISSION_READ_SECRETS = 'read'; -const PERMISSION_WRITE_SECRETS = 'write'; - -export { - PERMISSION_READ_SECRETS, - PERMISSION_WRITE_SECRETS -} \ No newline at end of file +export const PERMISSION_READ_SECRETS = 'read'; +export const PERMISSION_WRITE_SECRETS = 'write'; \ No newline at end of file diff --git a/backend/src/variables/secret.ts b/backend/src/variables/secret.ts index 31cbcf951..571e66b9d 100644 --- a/backend/src/variables/secret.ts +++ b/backend/src/variables/secret.ts @@ -1,8 +1,3 @@ // secrets -const SECRET_SHARED = 'shared'; -const SECRET_PERSONAL = 'personal'; - -export { - SECRET_SHARED, - SECRET_PERSONAL -} \ No newline at end of file +export const SECRET_SHARED = 'shared'; +export const SECRET_PERSONAL = 'personal'; \ No newline at end of file diff --git a/backend/src/variables/smtp.ts b/backend/src/variables/smtp.ts index 3ec46c4e7..f5dd05d0b 100644 --- a/backend/src/variables/smtp.ts +++ b/backend/src/variables/smtp.ts @@ -1,13 +1,5 @@ -const SMTP_HOST_SENDGRID = 'smtp.sendgrid.net'; -const SMTP_HOST_MAILGUN = 'smtp.mailgun.org'; -const SMTP_HOST_SOCKETLABS = 'smtp.socketlabs.com'; -const SMTP_HOST_ZOHOMAIL = 'smtp.zoho.com'; -const SMTP_HOST_GMAIL = 'smtp.gmail.com'; - -export { - SMTP_HOST_SENDGRID, - SMTP_HOST_MAILGUN, - SMTP_HOST_SOCKETLABS, - SMTP_HOST_ZOHOMAIL, - SMTP_HOST_GMAIL -} \ No newline at end of file +export const SMTP_HOST_SENDGRID = 'smtp.sendgrid.net'; +export const SMTP_HOST_MAILGUN = 'smtp.mailgun.org'; +export const SMTP_HOST_SOCKETLABS = 'smtp.socketlabs.com'; +export const SMTP_HOST_ZOHOMAIL = 'smtp.zoho.com'; +export const SMTP_HOST_GMAIL = 'smtp.gmail.com'; diff --git a/backend/src/variables/stripe.ts b/backend/src/variables/stripe.ts index ecdbd98ae..7b6ae8fa1 100644 --- a/backend/src/variables/stripe.ts +++ b/backend/src/variables/stripe.ts @@ -1,7 +1,2 @@ -const PLAN_STARTER = 'starter'; -const PLAN_PRO = 'pro'; - -export { - PLAN_STARTER, - PLAN_PRO -} \ No newline at end of file +export const PLAN_STARTER = 'starter'; +export const PLAN_PRO = 'pro'; \ No newline at end of file diff --git a/backend/src/variables/token.ts b/backend/src/variables/token.ts index ecb63990f..2ced95d9c 100644 --- a/backend/src/variables/token.ts +++ b/backend/src/variables/token.ts @@ -1,11 +1,4 @@ -const TOKEN_EMAIL_CONFIRMATION = 'emailConfirmation'; -const TOKEN_EMAIL_MFA = 'emailMfa'; -const TOKEN_EMAIL_ORG_INVITATION = 'organizationInvitation'; -const TOKEN_EMAIL_PASSWORD_RESET = 'passwordReset'; - -export { - TOKEN_EMAIL_CONFIRMATION, - TOKEN_EMAIL_MFA, - TOKEN_EMAIL_ORG_INVITATION, - TOKEN_EMAIL_PASSWORD_RESET -} \ No newline at end of file +export const TOKEN_EMAIL_CONFIRMATION = 'emailConfirmation'; +export const TOKEN_EMAIL_MFA = 'emailMfa'; +export const TOKEN_EMAIL_ORG_INVITATION = 'organizationInvitation'; +export const TOKEN_EMAIL_PASSWORD_RESET = 'passwordReset'; \ No newline at end of file diff --git a/backend/src/variables/user.ts b/backend/src/variables/user.ts index baa27d35d..7e5b53c4b 100644 --- a/backend/src/variables/user.ts +++ b/backend/src/variables/user.ts @@ -1,5 +1 @@ -const MFA_METHOD_EMAIL = 'email'; - -export { - MFA_METHOD_EMAIL -} \ No newline at end of file +export const MFA_METHOD_EMAIL = 'email'; \ No newline at end of file diff --git a/backend/src/variables/workspace.ts b/backend/src/variables/workspace.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/test-resources/env-vars.js b/backend/test-resources/env-vars.js index 6702649c1..3149a27c9 100644 --- a/backend/test-resources/env-vars.js +++ b/backend/test-resources/env-vars.js @@ -7,4 +7,6 @@ process.env.NODE_ENV = 'test'; process.env.JWT_SIGNUP_SECRET= "38ea90fb7998b92176080f457d890392" process.env.JWT_REFRESH_SECRET= "7764c7bbf3928ad501591a3e005eb364" process.env.JWT_AUTH_SECRET= "5239fea3a4720c0e524f814a540e14a2" -process.env.JWT_SERVICE_SECRET= "8509fb8b90c9b53e9e61d1e35826dcb5" \ No newline at end of file +process.env.JWT_SERVICE_SECRET= "8509fb8b90c9b53e9e61d1e35826dcb5" +process.env.ENCRYPTION_KEY="e05f54dffd58b5ab9b09e4c6fca7aff7" +process.env.ROOT_ENCRYPTION_KEY="MJA3DWJXjHiL6xjkUI2QCQuy/D+/SAbRNU1+rEo9gvQ=" diff --git a/backend/tests/helper/helper.ts b/backend/tests/helper/helper.ts index c59c8d43b..d72a101c1 100644 --- a/backend/tests/helper/helper.ts +++ b/backend/tests/helper/helper.ts @@ -10,7 +10,9 @@ const jsrp = require('jsrp'); // eslint-disable-next-line @typescript-eslint/no-var-requires const axios = require('axios'); import { plainTextWorkspaceKey, testWorkspaceId } from "../../src/utils/addDevelopmentUser"; -import { encryptSymmetric } from "../../src/utils/crypto"; +import { + encryptSymmetric128BitHexKeyUTF8 +} from '../../src/utils/crypto'; interface TokenData { token: string; @@ -64,7 +66,7 @@ export const getJWTFromTestUser = (): Promise => { export const getServiceTokenFromTestUser = async () => { const loggedInUserDetails = await getJWTFromTestUser() const randomBytes = crypto.randomBytes(16).toString('hex'); - const { ciphertext, iv, tag } = encryptSymmetric({ + const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8({ plaintext: plainTextWorkspaceKey, key: randomBytes, }); diff --git a/backend/tests/unit-tests/utils/crypto.test.ts b/backend/tests/unit-tests/utils/crypto.test.ts index 7509e53b9..d50b3057c 100644 --- a/backend/tests/unit-tests/utils/crypto.test.ts +++ b/backend/tests/unit-tests/utils/crypto.test.ts @@ -1,9 +1,7 @@ import { describe, test, expect } from '@jest/globals'; import { decryptAsymmetric, - decryptSymmetric, encryptAsymmetric, - encryptSymmetric } from '../../../src/utils/crypto'; describe('Crypto', () => { @@ -153,99 +151,4 @@ describe('Crypto', () => { }); }); }); - - describe('encryptSymmetric', () => { - let plaintext: string; - const key = '7e8ee7e5cc667b9c1829783ad31f36f4'; - - test('should encrypt plaintext with the given key', () => { - plaintext = 'secret-message'; - const { ciphertext, iv, tag } = encryptSymmetric({ plaintext, key }); - expect(ciphertext).toBeDefined(); - expect(iv).toBeDefined(); - expect(tag).toBeDefined(); - }); - - test('should throw an error when plaintext is undefined', () => { - const invalidKey = 'invalid-key'; - expect(() => { - encryptSymmetric({ plaintext, key: invalidKey }); - }).toThrowError('Invalid key length'); - }); - - test('should throw an error when invalid key is provided', () => { - plaintext = 'secret-message'; - const invalidKey = 'invalid-key'; - - expect(() => { - encryptSymmetric({ plaintext, key: invalidKey }); - }).toThrowError('Invalid key length'); - }); - }); - - describe('decryptSymmetric', () => { - const plaintext = 'secret-message'; - const key = '7e8ee7e5cc667b9c1829783ad31f36f4'; - const { ciphertext, iv, tag } = encryptSymmetric({ plaintext, key }); - - test('should decrypt encrypted plaintext', () => { - const result = decryptSymmetric({ - ciphertext, - iv, - tag, - key - }); - - expect(result).toBeDefined(); - expect(result).toEqual(plaintext); - }); - - test('should fail if ciphertext is modified', () => { - const modifieldCiphertext = 'abcdefghijklmnopqrstuvwxyz'; - expect(() => { - decryptSymmetric({ - ciphertext: modifieldCiphertext, - iv, - tag, - key - }); - }).toThrowError('Unsupported state or unable to authenticate data'); - }); - - test('should fail if iv is modified', () => { - const modifiedIv = 'abcdefghijklmnopqrstuvwxyz'; - expect(() => { - decryptSymmetric({ - ciphertext, - iv: modifiedIv, - tag, - key - }); - }).toThrowError('Unsupported state or unable to authenticate data'); - }); - - test('should fail if tag is modified', () => { - const modifiedTag = 'abcdefghijklmnopqrstuvwxyz'; - expect(() => { - decryptSymmetric({ - ciphertext, - iv, - tag: modifiedTag, - key - }); - }).toThrowError(/Invalid authentication tag length: \d+/); - }); - - test('should throw an error when decryption fails', () => { - const invalidKey = 'invalid-key'; - expect(() => { - decryptSymmetric({ - ciphertext, - iv, - tag, - key: invalidKey - }); - }).toThrowError('Invalid key length'); - }); - }); }); diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 4ace4c387..5f8bc7aae 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -2,7 +2,6 @@ package api import ( "fmt" - "net/http" "github.com/Infisical/infisical-merge/packages/config" "github.com/go-resty/resty/v2" @@ -180,19 +179,6 @@ func CallLogin2V2(httpClient *resty.Client, request GetLoginTwoV2Request) (GetLo SetBody(request). Post(fmt.Sprintf("%v/v2/auth/login2", config.INFISICAL_URL)) - cookies := response.Cookies() - // Find a cookie by name - cookieName := "jid" - var refreshToken *http.Cookie - for _, cookie := range cookies { - if cookie.Name == cookieName { - refreshToken = cookie - break - } - } - - loginTwoV2Response.RefreshToken = refreshToken.Value - if err != nil { return GetLoginTwoV2Response{}, fmt.Errorf("CallLogin2V2: Unable to complete api request [err=%s]", err) } @@ -261,26 +247,3 @@ func CallGetAccessibleEnvironments(httpClient *resty.Client, request GetAccessib return accessibleEnvironmentsResponse, nil } - -func CallGetNewAccessTokenWithRefreshToken(httpClient *resty.Client, refreshToken string) (GetNewAccessTokenWithRefreshTokenResponse, error) { - var newAccessToken GetNewAccessTokenWithRefreshTokenResponse - response, err := httpClient. - R(). - SetResult(&newAccessToken). - SetHeader("User-Agent", USER_AGENT). - SetCookie(&http.Cookie{ - Name: "jid", - Value: refreshToken, - }). - Post(fmt.Sprintf("%v/v1/auth/token", config.INFISICAL_URL)) - - if err != nil { - return GetNewAccessTokenWithRefreshTokenResponse{}, err - } - - if response.IsError() { - return GetNewAccessTokenWithRefreshTokenResponse{}, fmt.Errorf("CallGetNewAccessTokenWithRefreshToken: Unsuccessful response: [response=%v]", response) - } - - return newAccessToken, nil -} diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index bce2a34d6..71354c84c 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -281,7 +281,6 @@ type GetLoginTwoV2Response struct { ProtectedKey string `json:"protectedKey"` ProtectedKeyIV string `json:"protectedKeyIV"` ProtectedKeyTag string `json:"protectedKeyTag"` - RefreshToken string `json:"RefreshToken"` } type VerifyMfaTokenRequest struct { @@ -315,7 +314,3 @@ type VerifyMfaTokenErrorResponse struct { Application string `json:"application"` Extra []interface{} `json:"extra"` } - -type GetNewAccessTokenWithRefreshTokenResponse struct { - Token string `json:"token"` -} diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index ab0f531c2..f20a17853 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -97,7 +97,7 @@ var loginCmd = &cobra.Command{ loginOneResponse, loginTwoResponse, err := getFreshUserCredentials(email, password) if err != nil { - log.Warn().Msg("Unable to authenticate with the provided credentials, please ensure your email and password are correct") + fmt.Println("Unable to authenticate with the provided credentials, please try again") log.Debug().Err(err) return } @@ -244,10 +244,9 @@ var loginCmd = &cobra.Command{ } userCredentialsToBeStored := &models.UserCredentials{ - Email: email, - PrivateKey: string(decryptedPrivateKey), - JTWToken: loginTwoResponse.Token, - RefreshToken: loginTwoResponse.RefreshToken, + Email: email, + PrivateKey: string(decryptedPrivateKey), + JTWToken: loginTwoResponse.Token, } err = util.StoreUserCredsInKeyRing(userCredentialsToBeStored) @@ -415,7 +414,7 @@ func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2R }) if err != nil { - return nil, nil, err + util.HandleError(err) } // **** Login 2 diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 22f72e2da..b9b0ab7b5 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -5,10 +5,9 @@ import ( ) type UserCredentials struct { - Email string `json:"email"` - PrivateKey string `json:"privateKey"` - JTWToken string `json:"JTWToken"` - RefreshToken string `json:"RefreshToken"` + Email string `json:"email"` + PrivateKey string `json:"privateKey"` + JTWToken string `json:"JTWToken"` } // The file struct for Infisical config file diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index c93fc6027..9147a717a 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -9,7 +9,6 @@ import ( "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" "github.com/go-resty/resty/v2" - "github.com/rs/zerolog/log" ) type LoggedInUserDetails struct { @@ -97,20 +96,6 @@ func GetCurrentLoggedInUserDetails() (LoggedInUserDetails, error) { } isAuthenticated := api.CallIsAuthenticated(httpClient) - - if !isAuthenticated { - accessTokenResponse, _ := api.CallGetNewAccessTokenWithRefreshToken(httpClient, userCreds.RefreshToken) - if accessTokenResponse.Token != "" { - isAuthenticated = true - userCreds.JTWToken = accessTokenResponse.Token - - err = StoreUserCredsInKeyRing(&userCreds) - if err != nil { - log.Debug().Msg("unable to store your user credentials with new access token") - } - } - } - if !isAuthenticated { return LoggedInUserDetails{ IsUserLoggedIn: true, // was logged in diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index d96ac3899..8527c4059 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -74,12 +74,23 @@ func ConfigContainsEmail(users []models.LoggedInUser, email string) bool { } func RequireLogin() { - // get the config file that stores the current logged in user email - configFile, _ := GetConfigFile() + currentUserDetails, err := GetCurrentLoggedInUserDetails() - if configFile.LoggedInUserEmail == "" { + if err != nil { + HandleError(err, "unable to retrieve your login details") + } + + if !currentUserDetails.IsUserLoggedIn { PrintErrorMessageAndExit("You must be logged in to run this command. To login, run [infisical login]") } + + if currentUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login expired, please login in again. To login, run [infisical login]") + } + + if currentUserDetails.UserCredentials.Email == "" && currentUserDetails.UserCredentials.JTWToken == "" && currentUserDetails.UserCredentials.PrivateKey == "" { + PrintErrorMessageAndExit("One or more of your login details is empty. Please try logging in again via by running [infisical login]") + } } func RequireServiceToken() { diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 46cd3fb03..17e606050 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -1,6 +1,6 @@ import { forwardRef, ReactNode } from 'react'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { faCaretDown, faCheck, faChevronUp } from '@fortawesome/free-solid-svg-icons'; +import { faCaretDown, faCaretUp,faCheck } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as SelectPrimitive from '@radix-ui/react-select'; import { twMerge } from 'tailwind-merge'; @@ -63,7 +63,9 @@ export const Select = forwardRef( style={{ width: 'var(--radix-select-trigger-width)' }} > - +
+ +
{isLoading ? ( @@ -76,7 +78,9 @@ export const Select = forwardRef( )} - +
+ +
diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 791e3e385..938d9e428 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -261,9 +261,10 @@ export const AppLayout = ({ children }: LayoutProps) => { router.push(`/dashboard/${value}`); }} position="popper" - dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50" + dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 z-50 max-h-96 border-gray-700" > - {workspaces +
+ {workspaces .filter((ws) => ws.organization === currentOrg?._id) .map(({ _id, name }) => ( { {name} ))} - {/*
*/} +
+