diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..6a47c2536 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +backend/node_modules +frontend/node_modules \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit index 0b3d59a18..e235e5d79 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -3,3 +3,5 @@ . "$(dirname -- "$0")/_/husky.sh" npx lint-staged + +infisical scan git-changes --staged -v diff --git a/README.md b/README.md index 30dda9d14..1882f7f56 100644 --- a/README.md +++ b/README.md @@ -98,16 +98,10 @@ To scan your full git history, run: infisical scan --verbose ``` -To scan your uncommitted git changes, run: +Install pre commit hook to scan each commit before you push to your repository ``` -infisical scan git-changes --verbose -``` - -You can also scan your uncommited but staged changes by running the command below. This command can also be used as a pre-commit hook to prevent secret leak. - -``` -infisical scan git-changes --staged --verbose +infisical scan install --pre-commit-hook ``` Lean about Infisical's code scanning feature [here](https://infisical.com/docs/cli/scanning-overview) diff --git a/backend/package-lock.json b/backend/package-lock.json index 7e1ced5bf..1662929e7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -40,6 +40,7 @@ "libsodium-wrappers": "^0.7.10", "lodash": "^4.17.21", "mongoose": "^6.10.5", + "node-cache": "^5.1.2", "nodemailer": "^6.8.0", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", diff --git a/backend/package.json b/backend/package.json index dd5c3f799..bb4be61c1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -31,6 +31,7 @@ "libsodium-wrappers": "^0.7.10", "lodash": "^4.17.21", "mongoose": "^6.10.5", + "node-cache": "^5.1.2", "nodemailer": "^6.8.0", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 622076475..394154fea 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -5,7 +5,7 @@ const client = new InfisicalClient({ }); export const getPort = async () => (await client.getSecret('PORT')).secretValue || 4000; -export const getInviteOnlySignup = async () => (await client.getSecret('INVITE_ONLY_SIGNUP')).secretValue == undefined ? false : (await client.getSecret('INVITE_ONLY_SIGNUP')).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'; @@ -49,12 +49,19 @@ export const getSmtpUsername = async () => (await client.getSecret('SMTP_USERNAM export const getSmtpPassword = async () => (await client.getSecret('SMTP_PASSWORD')).secretValue; 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 getLicenseServerUrl = async () => (await client.getSecret('LICENSE_SERVER_URL')).secretValue || 'https://portal.infisical.com'; + +// TODO: deprecate from here export const getStripeProductStarter = async () => (await client.getSecret('STRIPE_PRODUCT_STARTER')).secretValue; export const getStripeProductPro = async () => (await client.getSecret('STRIPE_PRODUCT_PRO')).secretValue; export const getStripeProductTeam = async () => (await client.getSecret('STRIPE_PRODUCT_TEAM')).secretValue; export const getStripePublishableKey = async () => (await client.getSecret('STRIPE_PUBLISHABLE_KEY')).secretValue; export const getStripeSecretKey = async () => (await client.getSecret('STRIPE_SECRET_KEY')).secretValue; export const getStripeWebhookSecret = async () => (await client.getSecret('STRIPE_WEBHOOK_SECRET')).secretValue; + export const getTelemetryEnabled = async () => (await client.getSecret('TELEMETRY_ENABLED')).secretValue !== 'false' && true; export const getLoopsApiKey = async () => (await client.getSecret('LOOPS_API_KEY')).secretValue; export const getSmtpConfigured = async () => (await client.getSecret('SMTP_HOST')).secretValue == '' || (await client.getSecret('SMTP_HOST')).secretValue == undefined ? false : true diff --git a/backend/src/config/request.ts b/backend/src/config/request.ts index 2a9a8279a..e13469657 100644 --- a/backend/src/config/request.ts +++ b/backend/src/config/request.ts @@ -1,10 +1,24 @@ import axios from 'axios'; import axiosRetry from 'axios-retry'; +import { + getLicenseServerKeyAuthToken, + setLicenseServerKeyAuthToken, + getLicenseKeyAuthToken, + setLicenseKeyAuthToken +} from './storage'; +import { + getLicenseKey, + getLicenseServerKey, + getLicenseServerUrl +} from './index'; -const axiosInstance = axios.create(); +// should have JWT to interact with the license server +export const licenseServerKeyRequest = axios.create(); +export const licenseKeyRequest = axios.create(); +export const standardRequest = axios.create(); // add retry functionality to the axios instance -axiosRetry(axiosInstance, { +axiosRetry(standardRequest, { retries: 3, retryDelay: axiosRetry.exponentialDelay, // exponential back-off delay between retries retryCondition: (error) => { @@ -13,4 +27,98 @@ axiosRetry(axiosInstance, { }, }); -export default axiosInstance; \ No newline at end of file +export const refreshLicenseServerKeyToken = async () => { + const licenseServerKey = await getLicenseServerKey(); + const licenseServerUrl = await getLicenseServerUrl(); + + const { data: { token } } = await standardRequest.post( + `${licenseServerUrl}/api/auth/v1/license-server-login`, {}, + { + headers: { + 'X-API-KEY': licenseServerKey + } + } + ); + + setLicenseServerKeyAuthToken(token); + + return token; +} + +export const refreshLicenseKeyToken = async () => { + const licenseKey = await getLicenseKey(); + const licenseServerUrl = await getLicenseServerUrl(); + + const { data: { token } } = await standardRequest.post( + `${licenseServerUrl}/api/auth/v1/license-login`, {}, + { + headers: { + 'X-API-KEY': licenseKey + } + } + ); + + setLicenseKeyAuthToken(token); + + return token; +} + +licenseServerKeyRequest.interceptors.request.use((config) => { + const token = getLicenseServerKeyAuthToken(); + + if (token && config.headers) { + // eslint-disable-next-line no-param-reassign + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}, (err) => { + return Promise.reject(err); +}); + +licenseServerKeyRequest.interceptors.response.use((response) => { + return response +}, async function (err) { + const originalRequest = err.config; + + if (err.response.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + // refresh + const token = await refreshLicenseServerKeyToken(); + + axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; + return licenseServerKeyRequest(originalRequest); + } + + return Promise.reject(err); +}); + +licenseKeyRequest.interceptors.request.use((config) => { + const token = getLicenseKeyAuthToken(); + + if (token && config.headers) { + // eslint-disable-next-line no-param-reassign + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}, (err) => { + return Promise.reject(err); +}); + +licenseKeyRequest.interceptors.response.use((response) => { + return response +}, async function (err) { + const originalRequest = err.config; + + if (err.response.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + // refresh + const token = await refreshLicenseKeyToken(); + + axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; + return licenseKeyRequest(originalRequest); + } + + return Promise.reject(err); +}); \ No newline at end of file diff --git a/backend/src/config/storage.ts b/backend/src/config/storage.ts new file mode 100644 index 000000000..5638561ac --- /dev/null +++ b/backend/src/config/storage.ts @@ -0,0 +1,30 @@ +const MemoryLicenseServerKeyTokenStorage = () => { + let authToken: string; + + return { + setToken: (token: string) => { + authToken = token; + }, + getToken: () => authToken + }; +}; + +const MemoryLicenseKeyTokenStorage = () => { + let authToken: string; + + return { + setToken: (token: string) => { + authToken = token; + }, + getToken: () => authToken + }; +}; + +const licenseServerTokenStorage = MemoryLicenseServerKeyTokenStorage(); +const licenseTokenStorage = MemoryLicenseKeyTokenStorage(); + +export const getLicenseServerKeyAuthToken = licenseServerTokenStorage.getToken; +export const setLicenseServerKeyAuthToken = licenseServerTokenStorage.setToken; + +export const getLicenseKeyAuthToken = licenseTokenStorage.getToken; +export const setLicenseKeyAuthToken = licenseTokenStorage.setToken; \ No newline at end of file diff --git a/backend/src/controllers/v1/integrationAuthController.ts b/backend/src/controllers/v1/integrationAuthController.ts index b21a0cd42..0d5947a4f 100644 --- a/backend/src/controllers/v1/integrationAuthController.ts +++ b/backend/src/controllers/v1/integrationAuthController.ts @@ -16,7 +16,7 @@ import { INTEGRATION_VERCEL_API_URL, INTEGRATION_RAILWAY_API_URL } from '../../variables'; -import request from '../../config/request'; +import { standardRequest } from '../../config/request'; /*** * Return integration authorization with id [integrationAuthId] @@ -229,7 +229,7 @@ export const getIntegrationAuthVercelBranches = async (req: Request, res: Respon let branches: string[] = []; if (appId && appId !== '') { - const { data }: { data: VercelBranch[] } = await request.get( + const { data }: { data: VercelBranch[] } = await standardRequest.get( `${INTEGRATION_VERCEL_API_URL}/v1/integrations/git-branches`, { params, @@ -292,7 +292,7 @@ export const getIntegrationAuthRailwayEnvironments = async (req: Request, res: R projectId: appId } - const { data: { data: { environments: { edges } } } } = await request.post(INTEGRATION_RAILWAY_API_URL, { + const { data: { data: { environments: { edges } } } } = await standardRequest.post(INTEGRATION_RAILWAY_API_URL, { query, variables, }, { @@ -372,7 +372,7 @@ export const getIntegrationAuthRailwayServices = async (req: Request, res: Respo id: appId } - const { data: { data: { project: { services: { edges } } } } } = await request.post(INTEGRATION_RAILWAY_API_URL, { + const { data: { data: { project: { services: { edges } } } } } = await standardRequest.post(INTEGRATION_RAILWAY_API_URL, { query, variables }, { diff --git a/backend/src/controllers/v1/membershipOrgController.ts b/backend/src/controllers/v1/membershipOrgController.ts index e5714516e..60cdc9691 100644 --- a/backend/src/controllers/v1/membershipOrgController.ts +++ b/backend/src/controllers/v1/membershipOrgController.ts @@ -135,6 +135,7 @@ export const inviteUserToOrganization = async (req: Request, res: Response) => { } if (!inviteeMembershipOrg) { + await new MembershipOrg({ user: invitee, inviteEmail: inviteeEmail, @@ -246,6 +247,10 @@ export const verifyUserToOrganization = async (req: Request, res: Response) => { // membership can be approved and redirected to login/dashboard membershipOrg.status = ACCEPTED; await membershipOrg.save(); + + await updateSubscriptionOrgQuantity({ + organizationId + }); return res.status(200).send({ message: 'Successfully verified email', diff --git a/backend/src/controllers/v1/signupController.ts b/backend/src/controllers/v1/signupController.ts index 193699c15..e5af676b3 100644 --- a/backend/src/controllers/v1/signupController.ts +++ b/backend/src/controllers/v1/signupController.ts @@ -21,14 +21,6 @@ export const beginEmailSignup = async (req: Request, res: Response) => { try { email = req.body.email; - if (await getInviteOnlySignup()) { - // Only one user can create an account without being invited. The rest need to be invited in order to make an account - const userCount = await User.countDocuments({}) - if (userCount != 0) { - throw BadRequestError({ message: "New user sign ups are not allowed at this time. You must be invited to sign up." }) - } - } - const user = await User.findOne({ email }).select('+publicKey'); if (user && user?.publicKey) { // case: user has already completed account @@ -74,6 +66,14 @@ export const verifyEmailSignup = async (req: Request, res: Response) => { }); } + if (await getInviteOnlySignup()) { + // Only one user can create an account without being invited. The rest need to be invited in order to make an account + const userCount = await User.countDocuments({}) + if (userCount != 0) { + throw BadRequestError({ message: "New user sign ups are not allowed at this time. You must be invited to sign up." }) + } + } + // verify email if (await getSmtpConfigured()) { await checkEmailVerification({ diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 51c93182b..4e0bd0488 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -1,26 +1,24 @@ -import to from 'await-to-js'; import { Types } from 'mongoose'; import { Request, Response } from 'express'; -import { ISecret, Secret } from '../../models'; +import { ISecret, Secret, Workspace } from '../../models'; import { IAction, SecretVersion } from '../../ee/models'; import { SECRET_PERSONAL, - SECRET_SHARED, ACTION_ADD_SECRETS, ACTION_READ_SECRETS, ACTION_UPDATE_SECRETS, ACTION_DELETE_SECRETS } from '../../variables'; -import { UnauthorizedRequestError, ValidationError } from '../../utils/errors'; +import { UnauthorizedRequestError, WorkspaceNotFoundError } from '../../utils/errors'; import { EventService } from '../../services'; import { eventPushSecrets } from '../../events'; -import { EESecretService, EELogService } from '../../ee/services'; +import { EESecretService, EELogService, EELicenseService } from '../../ee/services'; import { TelemetryService, SecretService } from '../../services'; import { getChannelFromUserAgent } from '../../utils/posthog'; import { PERMISSION_WRITE_SECRETS } from '../../variables'; import { userHasNoAbility, userHasWorkspaceAccess, userHasWriteOnlyAbility } from '../../ee/helpers/checkMembershipPermissions'; import Tag from '../../models/tag'; -import _, { eq } from 'lodash'; +import _ from 'lodash'; import { BatchSecretRequest, BatchSecret @@ -48,6 +46,12 @@ export const batchSecrets = async (req: Request, res: Response) => { environment: string; requests: BatchSecretRequest[]; } = req.body; + + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw WorkspaceNotFoundError(); + + const orgPlan = await EELicenseService.getOrganizationPlan(workspace.organization.toString()); + const isPaid = orgPlan.tier < 1; const createSecrets: BatchSecret[] = []; const updateSecrets: BatchSecret[] = []; @@ -139,7 +143,8 @@ export const batchSecrets = async (req: Request, res: Response) => { environment, workspaceId, channel, - userAgent: req.headers?.['user-agent'] + userAgent: req.headers?.['user-agent'], + isPaid } }); } @@ -226,7 +231,8 @@ export const batchSecrets = async (req: Request, res: Response) => { environment, workspaceId, channel, - userAgent: req.headers?.['user-agent'] + userAgent: req.headers?.['user-agent'], + isPaid } }); } @@ -261,7 +267,8 @@ export const batchSecrets = async (req: Request, res: Response) => { environment, workspaceId, channel: channel, - userAgent: req.headers?.['user-agent'] + userAgent: req.headers?.['user-agent'], + isPaid } }); } @@ -376,6 +383,12 @@ export const createSecrets = async (req: Request, res: Response) => { } } + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw WorkspaceNotFoundError(); + + const orgPlan = await EELicenseService.getOrganizationPlan(workspace.organization.toString()); + const isPaid = orgPlan.tier < 1; + let listOfSecretsToCreate; if (Array.isArray(req.body.secrets)) { // case: create multiple secrets @@ -531,7 +544,8 @@ export const createSecrets = async (req: Request, res: Response) => { environment, workspaceId, channel: channel, - userAgent: req.headers?.['user-agent'] + userAgent: req.headers?.['user-agent'], + isPaid } }); } @@ -595,6 +609,12 @@ export const getSecrets = async (req: Request, res: Response) => { const normalizedPath = normalizePath(secretsPath as string) const folders = await getFoldersInDirectory(workspaceId as string, environment as string, normalizedPath) + const workspace = await Workspace.findById(workspaceId); + if (!workspace) throw WorkspaceNotFoundError(); + + const orgPlan = await EELicenseService.getOrganizationPlan(workspace.organization.toString()); + const isPaid = orgPlan.tier < 1; + // secrets to return let secrets: ISecret[] = []; @@ -727,7 +747,8 @@ export const getSecrets = async (req: Request, res: Response) => { environment, workspaceId, channel, - userAgent: req.headers?.['user-agent'] + userAgent: req.headers?.['user-agent'], + isPaid } }); } @@ -938,6 +959,12 @@ export const updateSecrets = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(key) }) + const workspace = await Workspace.findById(key); + if (!workspace) throw WorkspaceNotFoundError(); + + const orgPlan = await EELicenseService.getOrganizationPlan(workspace.organization.toString()); + const isPaid = orgPlan.tier < 1; + const postHogClient = await TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ @@ -950,7 +977,8 @@ export const updateSecrets = async (req: Request, res: Response) => { environment: workspaceSecretObj[key][0].environment, workspaceId: key, channel: channel, - userAgent: req.headers?.['user-agent'] + userAgent: req.headers?.['user-agent'], + isPaid } }); } @@ -1072,6 +1100,14 @@ export const deleteSecrets = async (req: Request, res: Response) => { workspaceId: new Types.ObjectId(key) }); + const organizationId = ( + await Workspace.findOne({ + _id: key + }) + )?.organization?.toString(); + const orgPlan = await EELicenseService.getOrganizationPlan(organizationId || ''); + const isPaid = orgPlan.slug != 'starter'; + const postHogClient = await TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ @@ -1084,7 +1120,8 @@ export const deleteSecrets = async (req: Request, res: Response) => { environment: workspaceSecretObj[key][0].environment, workspaceId: key, channel: channel, - userAgent: req.headers?.['user-agent'] + userAgent: req.headers?.['user-agent'], + isPaid } }); } diff --git a/backend/src/controllers/v2/signupController.ts b/backend/src/controllers/v2/signupController.ts index 7cfb3e454..d9e9a0447 100644 --- a/backend/src/controllers/v2/signupController.ts +++ b/backend/src/controllers/v2/signupController.ts @@ -7,8 +7,9 @@ import { } from '../../helpers/signup'; import { issueAuthTokens } from '../../helpers/auth'; import { INVITED, ACCEPTED } from '../../variables'; -import request from '../../config/request'; +import { standardRequest } from '../../config/request'; import { getLoopsApiKey, getHttpsEnabled } from '../../config'; +import { updateSubscriptionOrgQuantity } from '../../helpers/organization'; /** * Complete setting up user by adding their personal and auth information as part of the @@ -87,6 +88,19 @@ export const completeAccountSignup = async (req: Request, res: Response) => { user }); + // update organization membership statuses that are + // invited to completed with user attached + const membershipsToUpdate = await MembershipOrg.find({ + inviteEmail: email, + status: INVITED + }); + + membershipsToUpdate.forEach(async (membership) => { + await updateSubscriptionOrgQuantity({ + organizationId: membership.organization.toString() + }); + }); + // update organization membership statuses that are // invited to completed with user attached await MembershipOrg.updateMany( @@ -109,7 +123,7 @@ export const completeAccountSignup = async (req: Request, res: Response) => { // sending a welcome email to new users if (await getLoopsApiKey()) { - await request.post("https://app.loops.so/api/v1/events/send", { + await standardRequest.post("https://app.loops.so/api/v1/events/send", { "email": email, "eventName": "Sign Up", "firstName": firstName, @@ -206,9 +220,20 @@ export const completeAccountInvite = async (req: Request, res: Response) => { if (!user) throw new Error('Failed to complete account for non-existent user'); - + // update organization membership statuses that are // invited to completed with user attached + const membershipsToUpdate = await MembershipOrg.find({ + inviteEmail: email, + status: INVITED + }); + + membershipsToUpdate.forEach(async (membership) => { + await updateSubscriptionOrgQuantity({ + organizationId: membership.organization.toString() + }); + }); + await MembershipOrg.updateMany( { inviteEmail: email, diff --git a/backend/src/ee/controllers/v1/cloudProductsController.ts b/backend/src/ee/controllers/v1/cloudProductsController.ts new file mode 100644 index 000000000..54584ce82 --- /dev/null +++ b/backend/src/ee/controllers/v1/cloudProductsController.ts @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/node'; +import { Request, Response } from 'express'; +import { EELicenseService } from '../../services'; +import { getLicenseServerUrl } from '../../../config'; +import { licenseServerKeyRequest } from '../../../config/request'; + +/** + * Return available cloud product information. + * Note: Nicely formatted to easily construct a table from + * @param req + * @param res + * @returns + */ +export const getCloudProducts = async (req: Request, res: Response) => { + try { + const billingCycle = req.query['billing-cycle'] as string; + + if (EELicenseService.instanceType === 'cloud') { + const { data } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/cloud-products?billing-cycle=${billingCycle}` + ); + + return res.status(200).send(data); + } + } catch (err) { + Sentry.setUser({ email: req.user.email }); + Sentry.captureException(err); + } + + return res.status(200).send({ + head: [], + rows: [] + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/index.ts b/backend/src/ee/controllers/v1/index.ts index aaa697286..bf3992b17 100644 --- a/backend/src/ee/controllers/v1/index.ts +++ b/backend/src/ee/controllers/v1/index.ts @@ -1,15 +1,19 @@ import * as stripeController from './stripeController'; import * as secretController from './secretController'; import * as secretSnapshotController from './secretSnapshotController'; +import * as organizationsController from './organizationsController'; import * as workspaceController from './workspaceController'; import * as actionController from './actionController'; import * as membershipController from './membershipController'; +import * as cloudProductsController from './cloudProductsController'; export { stripeController, secretController, secretSnapshotController, + organizationsController, workspaceController, actionController, - membershipController + membershipController, + cloudProductsController } \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/organizationsController.ts b/backend/src/ee/controllers/v1/organizationsController.ts new file mode 100644 index 000000000..0bdf6e435 --- /dev/null +++ b/backend/src/ee/controllers/v1/organizationsController.ts @@ -0,0 +1,84 @@ +import { Request, Response } from 'express'; +import { getLicenseServerUrl } from '../../../config'; +import { licenseServerKeyRequest } from '../../../config/request'; +import { EELicenseService } from '../../services'; + +/** + * Return the organization's current plan and allowed feature set + */ +export const getOrganizationPlan = async (req: Request, res: Response) => { + const plan = await EELicenseService.getOrganizationPlan(req.organization._id.toString()); + + // cache fetched plan for organization + EELicenseService.localFeatureSet.set(req.organization._id.toString(), plan); + + return res.status(200).send({ + plan + }); +} + +/** + * Update the organization plan to product with id [productId] + * @param req + * @param res + * @returns + */ +export const updateOrganizationPlan = async (req: Request, res: Response) => { + const { + productId + } = req.body; + + const { data } = await licenseServerKeyRequest.patch( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/cloud-plan`, + { + productId + } + ); + + return res.status(200).send(data); +} + +/** + * Return the organization's payment methods on file + */ +export const getOrganizationPmtMethods = async (req: Request, res: Response) => { + const { data: { pmtMethods } } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods` + ); + + return res.status(200).send({ + pmtMethods + }); +} + +/** + * Return a Stripe session URL to add payment method for organization + */ +export const addOrganizationPmtMethod = async (req: Request, res: Response) => { + const { + success_url, + cancel_url + } = req.body; + + const { data: { url } } = await licenseServerKeyRequest.post( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods`, + { + success_url, + cancel_url + } + ); + + return res.status(200).send({ + url + }); +} + +export const deleteOrganizationPmtMethod = async (req: Request, res: Response) => { + const { pmtMethodId } = req.params; + + const { data } = await licenseServerKeyRequest.delete( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${req.organization.customerId}/billing-details/payment-methods/${pmtMethodId}`, + ); + + return res.status(200).send(data); +} \ No newline at end of file diff --git a/backend/src/ee/routes/v1/cloudProducts.ts b/backend/src/ee/routes/v1/cloudProducts.ts new file mode 100644 index 000000000..73af00aca --- /dev/null +++ b/backend/src/ee/routes/v1/cloudProducts.ts @@ -0,0 +1,20 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + validateRequest +} from '../../../middleware'; +import { query } from 'express-validator'; +import { cloudProductsController } from '../../controllers/v1'; + +router.get( + '/', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + query('billing-cycle').exists().isIn(['monthly', 'yearly']), + validateRequest, + cloudProductsController.getCloudProducts +); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 612715111..7568e45d6 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,11 +1,15 @@ import secret from './secret'; import secretSnapshot from './secretSnapshot'; +import organizations from './organizations'; import workspace from './workspace'; import action from './action'; +import cloudProducts from './cloudProducts'; export { secret, secretSnapshot, + organizations, workspace, - action + action, + cloudProducts } \ No newline at end of file diff --git a/backend/src/ee/routes/v1/organizations.ts b/backend/src/ee/routes/v1/organizations.ts new file mode 100644 index 000000000..fd9fd08e9 --- /dev/null +++ b/backend/src/ee/routes/v1/organizations.ts @@ -0,0 +1,87 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + requireOrganizationAuth, + validateRequest +} from '../../../middleware'; +import { param, body } from 'express-validator'; +import { organizationsController } from '../../controllers/v1'; +import { + OWNER, ADMIN, MEMBER, ACCEPTED +} from '../../../variables'; + +router.get( + '/:organizationId/plan', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + param('organizationId').exists().trim(), + validateRequest, + organizationsController.getOrganizationPlan +); + +router.patch( + '/:organizationId/plan', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + param('organizationId').exists().trim(), + body('productId').exists().isString(), + validateRequest, + organizationsController.updateOrganizationPlan +); + +router.get( + '/:organizationId/billing-details/payment-methods', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + param('organizationId').exists().trim(), + validateRequest, + organizationsController.getOrganizationPmtMethods +); + +router.post( + '/:organizationId/billing-details/payment-methods', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + param('organizationId').exists().trim(), + body('success_url').exists().isString(), + body('cancel_url').exists().isString(), + validateRequest, + organizationsController.addOrganizationPmtMethod +); + +router.delete( + '/:organizationId/billing-details/payment-methods/:pmtMethodId', + requireAuth({ + acceptedAuthModes: ['jwt', 'apiKey'] + }), + requireOrganizationAuth({ + acceptedRoles: [OWNER, ADMIN, MEMBER], + acceptedStatuses: [ACCEPTED] + }), + param('organizationId').exists().trim(), + validateRequest, + organizationsController.deleteOrganizationPmtMethod +); + +export default router; \ No newline at end of file diff --git a/backend/src/ee/routes/v1/secretSnapshot.ts b/backend/src/ee/routes/v1/secretSnapshot.ts index d10da4456..80aa7d1ee 100644 --- a/backend/src/ee/routes/v1/secretSnapshot.ts +++ b/backend/src/ee/routes/v1/secretSnapshot.ts @@ -7,7 +7,7 @@ import { requireAuth, validateRequest } from '../../../middleware'; -import { param, body } from 'express-validator'; +import { param } from 'express-validator'; import { ADMIN, MEMBER } from '../../../variables'; import { secretSnapshotController } from '../../controllers/v1'; diff --git a/backend/src/ee/services/EELicenseService.ts b/backend/src/ee/services/EELicenseService.ts index 4bd811340..38530dfa0 100644 --- a/backend/src/ee/services/EELicenseService.ts +++ b/backend/src/ee/services/EELicenseService.ts @@ -1,12 +1,124 @@ +import NodeCache from 'node-cache'; +import * as Sentry from '@sentry/node'; +import { + getLicenseKey, + getLicenseServerKey, + getLicenseServerUrl +} from '../../config'; +import { + licenseKeyRequest, + licenseServerKeyRequest, + refreshLicenseServerKeyToken, + refreshLicenseKeyToken +} from '../../config/request'; +import { Organization } from '../../models'; +import { OrganizationNotFoundError } from '../../utils/errors'; + +interface FeatureSet { + _id: string | null; + slug: 'starter' | 'team' | 'pro' | 'enterprise' | null; + tier: number | null; + projectLimit: number | null; + memberLimit: number | null; + secretVersioning: boolean; + pitRecovery: boolean; + rbac: boolean; + customRateLimits: boolean; + customAlerts: boolean; + auditLogs: boolean; +} + /** - * Class to handle Enterprise Edition license actions + * Class to handle license/plan configurations: + * - Infisical Cloud: Fetch and cache customer plans in [localFeatureSet] + * - Self-hosted regular: Use default global feature set + * - Self-hosted enterprise: Fetch and update global feature set */ class EELicenseService { - private readonly _isLicenseValid: boolean; + private readonly _isLicenseValid: boolean; // TODO: deprecate + + public instanceType: 'self-hosted' | 'enterprise-self-hosted' | 'cloud' = 'self-hosted'; + + public globalFeatureSet: FeatureSet = { + _id: null, + slug: null, + tier: -1, + projectLimit: null, + memberLimit: null, + secretVersioning: true, + pitRecovery: true, + rbac: true, + customRateLimits: true, + customAlerts: true, + auditLogs: false + } + + public localFeatureSet: NodeCache; - constructor(licenseKey: string) { + constructor() { this._isLicenseValid = true; + this.localFeatureSet = new NodeCache({ + stdTTL: 300 + }); + } + + public async getOrganizationPlan(organizationId: string) { + try { + if (this.instanceType === 'cloud') { + const cachedPlan = this.localFeatureSet.get(organizationId); + if (cachedPlan) return cachedPlan; + + const organization = await Organization.findById(organizationId); + if (!organization) throw OrganizationNotFoundError(); + + const { data: { currentPlan } } = await licenseServerKeyRequest.get( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${organization.customerId}/cloud-plan` + ); + + return currentPlan; + } + } catch (err) { + return this.globalFeatureSet; + } + + return this.globalFeatureSet; + } + + public async initGlobalFeatureSet() { + const licenseServerKey = await getLicenseServerKey(); + const licenseKey = await getLicenseKey(); + + try { + if (licenseServerKey) { + // license server key is present -> validate it + const token = await refreshLicenseServerKeyToken() + + if (token) { + this.instanceType = 'cloud'; + } + + return; + } + + if (licenseKey) { + // license key is present -> validate it + const token = await refreshLicenseKeyToken(); + + if (token) { + const { data: { currentPlan } } = await licenseKeyRequest.get( + `${await getLicenseServerUrl()}/api/license/v1/plan` + ); + + this.globalFeatureSet = currentPlan; + this.instanceType = 'enterprise-self-hosted'; + } + } + } catch (err) { + // case: self-hosted free + Sentry.setUser(null); + Sentry.captureException(err); + } } public get isLicenseValid(): boolean { @@ -14,4 +126,4 @@ class EELicenseService { } } -export default new EELicenseService('N/A'); \ No newline at end of file +export default new EELicenseService(); \ No newline at end of file diff --git a/backend/src/helpers/organization.ts b/backend/src/helpers/organization.ts index 9e67ebb00..43b1bea83 100644 --- a/backend/src/helpers/organization.ts +++ b/backend/src/helpers/organization.ts @@ -29,6 +29,16 @@ import { } from "../utils/errors"; import { validateUserClientForOrganization } from "../helpers/user"; import { validateServiceAccountClientForOrganization } from "../helpers/serviceAccount"; +import { + EELicenseService +} from '../ee/services'; +import { + getLicenseServerUrl +} from '../config'; +import { + licenseServerKeyRequest, + licenseKeyRequest +} from '../config/request'; /** * Validate accepted clients for organization with id [organizationId] @@ -228,30 +238,35 @@ const updateSubscriptionOrgQuantity = async ({ }); if (organization && organization.customerId) { - const quantity = await MembershipOrg.countDocuments({ - organization: organizationId, - status: ACCEPTED, - }); - - const stripe = new Stripe(await getStripeSecretKey(), { - apiVersion: "2022-08-01", - }); - - const subscription = ( - await stripe.subscriptions.list({ - customer: organization.customerId, - }) - ).data[0]; - - stripeSubscription = await stripe.subscriptions.update(subscription.id, { - items: [ + if (EELicenseService.instanceType === 'cloud') { + // instance of Infisical is a cloud instance + const quantity = await MembershipOrg.countDocuments({ + organization: new Types.ObjectId(organizationId), + status: ACCEPTED, + }); + + await licenseServerKeyRequest.patch( + `${await getLicenseServerUrl()}/api/license-server/v1/customers/${organization.customerId}/cloud-plan`, { - id: subscription.items.data[0].id, - price: subscription.items.data[0].price.id, - quantity, - }, - ], - }); + quantity + } + ); + } + + if (EELicenseService.instanceType === 'enterprise-self-hosted') { + // instance of Infisical is an enterprise self-hosted instance + + const usedSeats = await MembershipOrg.countDocuments({ + status: ACCEPTED + }); + + await licenseKeyRequest.patch( + `${await getLicenseServerUrl()}/api/license/v1/license`, + { + usedSeats + } + ); + } } return stripeSubscription; diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index 5047a1967..2c6a50e21 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -89,7 +89,7 @@ const validateClientForWorkspace = async ({ requiredPermissions }); - return ({ membership }); + return ({ membership, workspace }); } if (authData.authMode === AUTH_MODE_SERVICE_ACCOUNT && authData.authPayload instanceof ServiceAccount) { @@ -123,7 +123,7 @@ const validateClientForWorkspace = async ({ requiredPermissions }); - return ({ membership }); + return ({ membership, workspace }); } throw UnauthorizedRequestError({ diff --git a/backend/src/index.ts b/backend/src/index.ts index 3c9bd11c4..c3b2ec726 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,198 +1,210 @@ -import dotenv from 'dotenv'; +import dotenv from "dotenv"; 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 { setUpHealthEndpoint } from './services/health'; -import { initSmtp } from './services/smtp'; -import { TelemetryService } from './services'; -import { setTransporter } from './helpers/nodemailer'; -import { createTestUserForDevelopment } from './utils/addDevelopmentUser'; +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'); +const { patchRouterParam } = require("./utils/patchAsyncRoutes"); -import cookieParser from 'cookie-parser'; -import swaggerUi = require('swagger-ui-express'); +import cookieParser from "cookie-parser"; +import swaggerUi = require("swagger-ui-express"); // eslint-disable-next-line @typescript-eslint/no-var-requires -const swaggerFile = require('../spec.json'); +const swaggerFile = require("../spec.json"); // eslint-disable-next-line @typescript-eslint/no-var-requires -const requestIp = require('request-ip'); -import { apiLimiter } from './helpers/rateLimiter'; +const requestIp = require("request-ip"); +import { apiLimiter } from "./helpers/rateLimiter"; import { - workspace as eeWorkspaceRouter, - secret as eeSecretRouter, - secretSnapshot as eeSecretSnapshotRouter, - action as eeActionRouter -} from './ee/routes/v1'; + workspace as eeWorkspaceRouter, + secret as eeSecretRouter, + secretSnapshot as eeSecretSnapshotRouter, + action as eeActionRouter, + organizations as eeOrganizationsRouter, + cloudProducts as eeCloudProductsRouter, +} from "./ee/routes/v1"; import { - signup as v1SignupRouter, - auth as v1AuthRouter, - bot as v1BotRouter, - organization as v1OrganizationRouter, - workspace as v1WorkspaceRouter, - membershipOrg as v1MembershipOrgRouter, - membership as v1MembershipRouter, - key as v1KeyRouter, - inviteOrg as v1InviteOrgRouter, - oauth as v1OAuth, - user as v1UserRouter, - userAction as v1UserActionRouter, - secret as v1SecretRouter, - serviceToken as v1ServiceTokenRouter, - password as v1PasswordRouter, - stripe as v1StripeRouter, - integration as v1IntegrationRouter, - integrationAuth as v1IntegrationAuthRouter, - secretsFolder as v1SecretsFolder -} from './routes/v1'; + signup as v1SignupRouter, + auth as v1AuthRouter, + bot as v1BotRouter, + organization as v1OrganizationRouter, + workspace as v1WorkspaceRouter, + membershipOrg as v1MembershipOrgRouter, + membership as v1MembershipRouter, + key as v1KeyRouter, + inviteOrg as v1InviteOrgRouter, + oauth as v1OAuth, + user as v1UserRouter, + userAction as v1UserActionRouter, + secret as v1SecretRouter, + serviceToken as v1ServiceTokenRouter, + password as v1PasswordRouter, + stripe as v1StripeRouter, + integration as v1IntegrationRouter, + integrationAuth as v1IntegrationAuthRouter, + secretsFolder as v1SecretsFolder, +} from "./routes/v1"; import { - signup as v2SignupRouter, - auth as v2AuthRouter, - users as v2UsersRouter, - organizations as v2OrganizationsRouter, - workspace as v2WorkspaceRouter, - secret as v2SecretRouter, // begin to phase out - secrets as v2SecretsRouter, - serviceTokenData as v2ServiceTokenDataRouter, - serviceAccounts as v2ServiceAccountsRouter, - apiKeyData as v2APIKeyDataRouter, - environment as v2EnvironmentRouter, - tags as v2TagsRouter, -} from './routes/v2'; + signup as v2SignupRouter, + auth as v2AuthRouter, + users as v2UsersRouter, + organizations as v2OrganizationsRouter, + workspace as v2WorkspaceRouter, + secret as v2SecretRouter, // begin to phase out + secrets as v2SecretsRouter, + serviceTokenData as v2ServiceTokenDataRouter, + serviceAccounts as v2ServiceAccountsRouter, + apiKeyData as v2APIKeyDataRouter, + environment as v2EnvironmentRouter, + tags as v2TagsRouter, +} from "./routes/v2"; import { - auth as v3AuthRouter, - secrets as v3SecretsRouter, - signup as v3SignupRouter, - workspaces as v3WorkspacesRouter, -} from './routes/v3'; -import { healthCheck } from './routes/status'; -import { getLogger } from './utils/logger'; -import { RouteNotFoundError } from './utils/errors'; -import { requestErrorHandler } from './middleware/requestErrorHandler'; + auth as v3AuthRouter, + secrets as v3SecretsRouter, + signup as v3SignupRouter, + workspaces as v3WorkspacesRouter, +} from "./routes/v3"; +import { healthCheck } from "./routes/status"; +import { getLogger } from "./utils/logger"; +import { RouteNotFoundError } from "./utils/errors"; +import { requestErrorHandler } from "./middleware/requestErrorHandler"; import { - getMongoURL, - getNodeEnv, - getPort, - getSentryDSN, - getSiteURL, -} from './config'; -import { initializePassport } from './utils/auth'; + getMongoURL, + getNodeEnv, + getPort, + getSentryDSN, + getSiteURL, +} from "./config"; +import { initializePassport } from "./utils/auth"; const main = async () => { - TelemetryService.logTelemetryMessage(); - setTransporter(await initSmtp()); + TelemetryService.logTelemetryMessage(); + setTransporter(await initSmtp()); - const mongoURL = await getMongoURL(); - await DatabaseService.initDatabase(mongoURL); - if ((await getNodeEnv()) !== 'test') { - Sentry.init({ - dsn: await getSentryDSN(), - tracesSampleRate: 1.0, - debug: await getNodeEnv() === 'production' ? false : true, - environment: await getNodeEnv() - }); - } + await EELicenseService.initGlobalFeatureSet(); - patchRouterParam(); - const app = express(); - - await initializePassport(); - - app.enable('trust proxy'); - app.use(express.json()); - app.use(cookieParser()); - app.use( - cors({ - credentials: true, - origin: await getSiteURL() - }) - ); - - app.use(requestIp.mw()); - - if ((await getNodeEnv()) === 'production') { - // enable app-wide rate-limiting + helmet security - // in production - app.disable('x-powered-by'); - app.use(apiLimiter); - app.use(helmet()); - } - - // (EE) routes - app.use('/api/v1/secret', eeSecretRouter); - app.use('/api/v1/secret-snapshot', eeSecretSnapshotRouter); - app.use('/api/v1/workspace', eeWorkspaceRouter); - app.use('/api/v1/action', eeActionRouter); - - // v1 routes (default) - app.use('/api/v1/signup', v1SignupRouter); - app.use('/api/v1/auth', v1AuthRouter); - app.use('/api/v1/oauth', v1OAuth); - app.use('/api/v1/bot', v1BotRouter); - app.use('/api/v1/user', v1UserRouter); - app.use('/api/v1/user-action', v1UserActionRouter); - app.use('/api/v1/organization', v1OrganizationRouter); - app.use('/api/v1/workspace', v1WorkspaceRouter); - app.use('/api/v1/membership-org', v1MembershipOrgRouter); - 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/password', v1PasswordRouter); - app.use('/api/v1/stripe', v1StripeRouter); - app.use('/api/v1/integration', v1IntegrationRouter); - app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); - app.use('/api/v1/folder', v1SecretsFolder) - - // v2 routes (improvements) - app.use('/api/v2/signup', v2SignupRouter); - app.use('/api/v2/auth', v2AuthRouter); - app.use('/api/v2/users', v2UsersRouter); - app.use('/api/v2/organizations', v2OrganizationsRouter); - app.use('/api/v2/workspace', v2EnvironmentRouter); - app.use('/api/v2/workspace', v2TagsRouter); - app.use('/api/v2/workspace', v2WorkspaceRouter); - app.use('/api/v2/secret', v2SecretRouter); // deprecated - app.use('/api/v2/secrets', v2SecretsRouter); - app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route - app.use('/api/v2/service-accounts', v2ServiceAccountsRouter); // new - app.use('/api/v2/api-key', v2APIKeyDataRouter); - - // v3 routes (experimental) - app.use('/api/v3/auth', v3AuthRouter); - app.use('/api/v3/secrets', v3SecretsRouter); - app.use('/api/v3/workspaces', v3WorkspacesRouter); - app.use('/api/v3/signup', v3SignupRouter); - - // api docs - app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)) - - // Server status - app.use('/api', healthCheck) - - //* Handle unrouted requests and respond with proper error message as well as status code - app.use((req, res, next) => { - if (res.headersSent) return next(); - next(RouteNotFoundError({ message: `The requested source '(${req.method})${req.url}' was not found` })) - }) - - app.use(requestErrorHandler) - - const server = app.listen(await getPort(), async () => { - (await getLogger("backend-main")).info(`Server started listening at port ${await getPort()}`) + 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(), }); + } - await createTestUserForDevelopment(); - setUpHealthEndpoint(server); + patchRouterParam(); + const app = express(); - server.on('close', async () => { - await DatabaseService.closeDatabase(); + await initializePassport(); + + app.enable("trust proxy"); + app.use(express.json()); + app.use(cookieParser()); + app.use( + cors({ + credentials: true, + origin: await getSiteURL(), }) + ); - return server; -} + app.use(requestIp.mw()); -export default main(); \ No newline at end of file + if ((await getNodeEnv()) === "production") { + // enable app-wide rate-limiting + helmet security + // in production + app.disable("x-powered-by"); + app.use(apiLimiter); + app.use(helmet()); + } + + // (EE) routes + app.use("/api/v1/secret", eeSecretRouter); + app.use("/api/v1/secret-snapshot", eeSecretSnapshotRouter); + app.use("/api/v1/workspace", eeWorkspaceRouter); + app.use("/api/v1/action", eeActionRouter); + app.use("/api/v1/organizations", eeOrganizationsRouter); + app.use("/api/v1/cloud-products", eeCloudProductsRouter); + + // v1 routes (default) + app.use("/api/v1/signup", v1SignupRouter); + app.use("/api/v1/auth", v1AuthRouter); + app.use("/api/v1/oauth", v1OAuth); + app.use("/api/v1/bot", v1BotRouter); + app.use("/api/v1/user", v1UserRouter); + app.use("/api/v1/user-action", v1UserActionRouter); + app.use("/api/v1/organization", v1OrganizationRouter); + app.use("/api/v1/workspace", v1WorkspaceRouter); + app.use("/api/v1/membership-org", v1MembershipOrgRouter); + 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/password", v1PasswordRouter); + app.use("/api/v1/stripe", v1StripeRouter); + app.use("/api/v1/integration", v1IntegrationRouter); + app.use("/api/v1/integration-auth", v1IntegrationAuthRouter); + app.use("/api/v1/folder", v1SecretsFolder); + + // v2 routes (improvements) + app.use("/api/v2/signup", v2SignupRouter); + app.use("/api/v2/auth", v2AuthRouter); + app.use("/api/v2/users", v2UsersRouter); + app.use("/api/v2/organizations", v2OrganizationsRouter); + app.use("/api/v2/workspace", v2EnvironmentRouter); + app.use("/api/v2/workspace", v2TagsRouter); + app.use("/api/v2/workspace", v2WorkspaceRouter); + app.use("/api/v2/secret", v2SecretRouter); // deprecated + app.use("/api/v2/secrets", v2SecretsRouter); + app.use("/api/v2/service-token", v2ServiceTokenDataRouter); // TODO: turn into plural route + app.use("/api/v2/service-accounts", v2ServiceAccountsRouter); // new + app.use("/api/v2/api-key", v2APIKeyDataRouter); + + // v3 routes (experimental) + app.use("/api/v3/auth", v3AuthRouter); + app.use("/api/v3/secrets", v3SecretsRouter); + app.use("/api/v3/workspaces", v3WorkspacesRouter); + app.use("/api/v3/signup", v3SignupRouter); + + // api docs + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerFile)); + + // Server status + app.use("/api", healthCheck); + + //* Handle unrouted requests and respond with proper error message as well as status code + app.use((req, res, next) => { + if (res.headersSent) return next(); + next( + RouteNotFoundError({ + message: `The requested source '(${req.method})${req.url}' was not found`, + }) + ); + }); + + app.use(requestErrorHandler); + + const server = app.listen(await getPort(), async () => { + (await getLogger("backend-main")).info( + `Server started listening at port ${await getPort()}` + ); + }); + + await createTestUserForDevelopment(); + setUpHealthEndpoint(server); + + server.on("close", async () => { + await DatabaseService.closeDatabase(); + }); + + return server; +}; + +export default main(); diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index a748f199f..fa0020d61 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -1,6 +1,6 @@ import { Octokit } from "@octokit/rest"; import { IIntegrationAuth } from "../models"; -import request from "../config/request"; +import { standardRequest } from "../config/request"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_AWS_PARAMETER_STORE, @@ -134,7 +134,7 @@ const getApps = async ({ */ const getAppsHeroku = async ({ accessToken }: { accessToken: string }) => { const res = ( - await request.get(`${INTEGRATION_HEROKU_API_URL}/apps`, { + await standardRequest.get(`${INTEGRATION_HEROKU_API_URL}/apps`, { headers: { Accept: "application/vnd.heroku+json; version=3", Authorization: `Bearer ${accessToken}`, @@ -164,7 +164,7 @@ const getAppsVercel = async ({ accessToken: string; }) => { const res = ( - await request.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { + await standardRequest.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json", @@ -208,7 +208,7 @@ const getAppsNetlify = async ({ accessToken }: { accessToken: string }) => { filter: 'all' }); - const { data } = await request.get( + const { data } = await standardRequest.get( `${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, { params, @@ -310,7 +310,7 @@ const getAppsGithub = async ({ accessToken }: { accessToken: string }) => { */ const getAppsRender = async ({ accessToken }: { accessToken: string }) => { const res = ( - await request.get(`${INTEGRATION_RENDER_API_URL}/v1/services`, { + await standardRequest.get(`${INTEGRATION_RENDER_API_URL}/v1/services`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", @@ -358,7 +358,7 @@ const getAppsRailway = async ({ accessToken }: { accessToken: string }) => { projects: { edges }, }, }, - } = await request.post( + } = await standardRequest.post( INTEGRATION_RAILWAY_API_URL, { query, @@ -402,7 +402,7 @@ const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => { `; const res = ( - await request.post( + await standardRequest.post( INTEGRATION_FLYIO_API_URL, { query, @@ -436,7 +436,7 @@ const getAppsFlyio = async ({ accessToken }: { accessToken: string }) => { */ const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => { const res = ( - await request.get(`${INTEGRATION_CIRCLECI_API_URL}/v1.1/projects`, { + await standardRequest.get(`${INTEGRATION_CIRCLECI_API_URL}/v1.1/projects`, { headers: { "Circle-Token": accessToken, "Accept-Encoding": "application/json", @@ -455,7 +455,7 @@ const getAppsCircleCI = async ({ accessToken }: { accessToken: string }) => { const getAppsTravisCI = async ({ accessToken }: { accessToken: string }) => { const res = ( - await request.get(`${INTEGRATION_TRAVISCI_API_URL}/repos`, { + await standardRequest.get(`${INTEGRATION_TRAVISCI_API_URL}/repos`, { headers: { Authorization: `token ${accessToken}`, "Accept-Encoding": "application/json", @@ -502,7 +502,7 @@ const getAppsGitlab = async ({ per_page: String(perPage), }); - const { data } = await request.get( + const { data } = await standardRequest.get( `${INTEGRATION_GITLAB_API_URL}/v4/groups/${teamId}/projects`, { params, @@ -530,7 +530,7 @@ const getAppsGitlab = async ({ // case: fetch projects for individual in GitLab const { id } = ( - await request.get(`${INTEGRATION_GITLAB_API_URL}/v4/user`, { + await standardRequest.get(`${INTEGRATION_GITLAB_API_URL}/v4/user`, { headers: { Authorization: `Bearer ${accessToken}`, "Accept-Encoding": "application/json", @@ -544,7 +544,7 @@ const getAppsGitlab = async ({ per_page: String(perPage), }); - const { data } = await request.get( + const { data } = await standardRequest.get( `${INTEGRATION_GITLAB_API_URL}/v4/users/${id}/projects`, { params, @@ -581,7 +581,7 @@ const getAppsGitlab = async ({ * @returns {String} apps.name - name of Supabase app */ const getAppsSupabase = async ({ accessToken }: { accessToken: string }) => { - const { data } = await request.get( + const { data } = await standardRequest.get( `${INTEGRATION_SUPABASE_API_URL}/v1/projects`, { headers: { diff --git a/backend/src/integrations/exchange.ts b/backend/src/integrations/exchange.ts index f7bb0222b..a4d5f5b06 100644 --- a/backend/src/integrations/exchange.ts +++ b/backend/src/integrations/exchange.ts @@ -1,4 +1,4 @@ -import request from "../config/request"; +import { standardRequest } from "../config/request"; import { INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -142,7 +142,7 @@ const exchangeCodeAzure = async ({ code }: { code: string }) => { const accessExpiresAt = new Date(); const res: ExchangeCodeAzureResponse = ( - await request.post( + await standardRequest.post( INTEGRATION_AZURE_TOKEN_URL, new URLSearchParams({ grant_type: "authorization_code", @@ -178,7 +178,7 @@ const exchangeCodeHeroku = async ({ code }: { code: string }) => { const accessExpiresAt = new Date(); const res: ExchangeCodeHerokuResponse = ( - await request.post( + await standardRequest.post( INTEGRATION_HEROKU_TOKEN_URL, new URLSearchParams({ grant_type: "authorization_code", @@ -209,7 +209,7 @@ const exchangeCodeHeroku = async ({ code }: { code: string }) => { */ const exchangeCodeVercel = async ({ code }: { code: string }) => { const res: ExchangeCodeVercelResponse = ( - await request.post( + await standardRequest.post( INTEGRATION_VERCEL_TOKEN_URL, new URLSearchParams({ code: code, @@ -240,7 +240,7 @@ const exchangeCodeVercel = async ({ code }: { code: string }) => { */ const exchangeCodeNetlify = async ({ code }: { code: string }) => { const res: ExchangeCodeNetlifyResponse = ( - await request.post( + await standardRequest.post( INTEGRATION_NETLIFY_TOKEN_URL, new URLSearchParams({ grant_type: "authorization_code", @@ -252,14 +252,14 @@ const exchangeCodeNetlify = async ({ code }: { code: string }) => { ) ).data; - const res2 = await request.get("https://api.netlify.com/api/v1/sites", { + const res2 = await standardRequest.get("https://api.netlify.com/api/v1/sites", { headers: { Authorization: `Bearer ${res.access_token}`, }, }); const res3 = ( - await request.get("https://api.netlify.com/api/v1/accounts", { + await standardRequest.get("https://api.netlify.com/api/v1/accounts", { headers: { Authorization: `Bearer ${res.access_token}`, }, @@ -287,7 +287,7 @@ const exchangeCodeNetlify = async ({ code }: { code: string }) => { */ const exchangeCodeGithub = async ({ code }: { code: string }) => { const res: ExchangeCodeGithubResponse = ( - await request.get(INTEGRATION_GITHUB_TOKEN_URL, { + await standardRequest.get(INTEGRATION_GITHUB_TOKEN_URL, { params: { client_id: await getClientIdGitHub(), client_secret: await getClientSecretGitHub(), @@ -321,7 +321,7 @@ const exchangeCodeGithub = async ({ code }: { code: string }) => { const exchangeCodeGitlab = async ({ code }: { code: string }) => { const accessExpiresAt = new Date(); const res: ExchangeCodeGitlabResponse = ( - await request.post( + await standardRequest.post( INTEGRATION_GITLAB_TOKEN_URL, new URLSearchParams({ grant_type: "authorization_code", diff --git a/backend/src/integrations/refresh.ts b/backend/src/integrations/refresh.ts index 823d05e69..0c401bf15 100644 --- a/backend/src/integrations/refresh.ts +++ b/backend/src/integrations/refresh.ts @@ -1,4 +1,4 @@ -import request from "../config/request"; +import { standardRequest } from "../config/request"; import { IIntegrationAuth } from "../models"; import { INTEGRATION_AZURE_KEY_VAULT, @@ -121,7 +121,7 @@ const exchangeRefreshAzure = async ({ refreshToken: string; }) => { const accessExpiresAt = new Date(); - const { data }: { data: RefreshTokenAzureResponse } = await request.post( + const { data }: { data: RefreshTokenAzureResponse } = await standardRequest.post( INTEGRATION_AZURE_TOKEN_URL, new URLSearchParams({ client_id: await getClientIdAzure(), @@ -158,7 +158,7 @@ const exchangeRefreshHeroku = async ({ data, }: { data: RefreshTokenHerokuResponse; - } = await request.post( + } = await standardRequest.post( INTEGRATION_HEROKU_TOKEN_URL, new URLSearchParams({ grant_type: "refresh_token", @@ -193,7 +193,7 @@ const exchangeRefreshGitLab = async ({ data, }: { data: RefreshTokenGitLabResponse; - } = await request.post( + } = await standardRequest.post( INTEGRATION_GITLAB_TOKEN_URL, new URLSearchParams({ grant_type: "refresh_token", diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index ec55eca77..d659af295 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -37,8 +37,7 @@ import { INTEGRATION_TRAVISCI_API_URL, INTEGRATION_SUPABASE_API_URL } from "../variables"; -import request from '../config/request'; -import axios from "axios"; +import { standardRequest} from '../config/request'; /** * Sync/push [secrets] to [app] in integration named [integration] @@ -215,7 +214,7 @@ const syncSecretsAzureKeyVault = async ({ let result: GetAzureKeyVaultSecret[] = []; try { while (url) { - const res = await request.get(url, { + const res = await standardRequest.get(url, { headers: { Authorization: `Bearer ${accessToken}` } @@ -242,7 +241,7 @@ const syncSecretsAzureKeyVault = async ({ lastSlashIndex = getAzureKeyVaultSecret.id.lastIndexOf('/'); } - const azureKeyVaultSecret = await request.get(`${getAzureKeyVaultSecret.id}?api-version=7.3`, { + const azureKeyVaultSecret = await standardRequest.get(`${getAzureKeyVaultSecret.id}?api-version=7.3`, { headers: { 'Authorization': `Bearer ${accessToken}` } @@ -308,7 +307,7 @@ const syncSecretsAzureKeyVault = async ({ while (!isSecretSet && maxTries > 0) { // try to set secret try { - await request.put( + await standardRequest.put( `${integration.app}/secrets/${key}?api-version=7.3`, { value @@ -325,7 +324,7 @@ const syncSecretsAzureKeyVault = async ({ } catch (err) { const error: any = err; if (error?.response?.data?.error?.innererror?.code === 'ObjectIsDeletedButRecoverable') { - await request.post( + await standardRequest.post( `${integration.app}/deletedsecrets/${key}/recover?api-version=7.3`, {}, { headers: { @@ -355,7 +354,7 @@ const syncSecretsAzureKeyVault = async ({ for await (const deleteSecret of deleteSecrets) { const { key } = deleteSecret; - await request.delete(`${integration.app}/secrets/${key}?api-version=7.3`, { + await standardRequest.delete(`${integration.app}/secrets/${key}?api-version=7.3`, { headers: { 'Authorization': `Bearer ${accessToken}` } @@ -568,7 +567,7 @@ const syncSecretsHeroku = async ({ }) => { try { const herokuSecrets = ( - await request.get( + await standardRequest.get( `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, { headers: { @@ -586,7 +585,7 @@ const syncSecretsHeroku = async ({ } }); - await request.patch( + await standardRequest.patch( `${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`, secrets, { @@ -642,7 +641,7 @@ const syncSecretsVercel = async ({ : {}), }; - const vercelSecrets: VercelSecret[] = (await request.get( + const vercelSecrets: VercelSecret[] = (await standardRequest.get( `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`, { params, @@ -675,7 +674,7 @@ const syncSecretsVercel = async ({ for await (const vercelSecret of vercelSecrets) { if (vercelSecret.type === 'encrypted') { // case: secret is encrypted -> need to decrypt - const decryptedSecret = (await request.get( + const decryptedSecret = (await standardRequest.get( `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${vercelSecret.id}`, { params, @@ -747,7 +746,7 @@ const syncSecretsVercel = async ({ // Sync/push new secrets if (newSecrets.length > 0) { - await request.post( + await standardRequest.post( `${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`, newSecrets, { @@ -763,7 +762,7 @@ const syncSecretsVercel = async ({ for await (const secret of updateSecrets) { if (secret.type !== 'sensitive') { const { id, ...updatedSecret } = secret; - await request.patch( + await standardRequest.patch( `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, updatedSecret, { @@ -778,7 +777,7 @@ const syncSecretsVercel = async ({ } for await (const secret of deleteSecrets) { - await request.delete( + await standardRequest.delete( `${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`, { params, @@ -837,7 +836,7 @@ const syncSecretsNetlify = async ({ }); const res = ( - await request.get( + await standardRequest.get( `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, { params: getParams, @@ -951,7 +950,7 @@ const syncSecretsNetlify = async ({ }); if (newSecrets.length > 0) { - await request.post( + await standardRequest.post( `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`, newSecrets, { @@ -966,7 +965,7 @@ const syncSecretsNetlify = async ({ if (updateSecrets.length > 0) { updateSecrets.forEach(async (secret: NetlifySecret) => { - await request.patch( + await standardRequest.patch( `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`, { context: secret.values[0].context, @@ -985,7 +984,7 @@ const syncSecretsNetlify = async ({ if (deleteSecrets.length > 0) { deleteSecrets.forEach(async (key: string) => { - await request.delete( + await standardRequest.delete( `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${key}`, { params: syncParams, @@ -1000,7 +999,7 @@ const syncSecretsNetlify = async ({ if (deleteSecretValues.length > 0) { deleteSecretValues.forEach(async (secret: NetlifySecret) => { - await request.delete( + await standardRequest.delete( `${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}/value/${secret.values[0].id}`, { params: syncParams, @@ -1151,7 +1150,7 @@ const syncSecretsRender = async ({ accessToken: string; }) => { try { - await request.put( + await standardRequest.put( `${INTEGRATION_RENDER_API_URL}/v1/services/${integration.appId}/env-vars`, Object.keys(secrets).map((key) => ({ key, @@ -1203,7 +1202,7 @@ const syncSecretsRailway = async ({ variables: secrets }; - await request.post(INTEGRATION_RAILWAY_API_URL, { + await standardRequest.post(INTEGRATION_RAILWAY_API_URL, { query, variables: { input, @@ -1261,7 +1260,7 @@ const syncSecretsFlyio = async ({ } `; - await request.post(INTEGRATION_FLYIO_API_URL, { + await standardRequest.post(INTEGRATION_FLYIO_API_URL, { query: SetSecrets, variables: { input: { @@ -1296,7 +1295,7 @@ const syncSecretsFlyio = async ({ } }`; - const getSecretsRes = (await request.post(INTEGRATION_FLYIO_API_URL, { + const getSecretsRes = (await standardRequest.post(INTEGRATION_FLYIO_API_URL, { query: GetSecrets, variables: { appName: integration.app, @@ -1332,7 +1331,7 @@ const syncSecretsFlyio = async ({ } }`; - await request.post(INTEGRATION_FLYIO_API_URL, { + await standardRequest.post(INTEGRATION_FLYIO_API_URL, { query: DeleteSecrets, variables: { input: { @@ -1373,7 +1372,7 @@ const syncSecretsCircleCI = async ({ }) => { try { const circleciOrganizationDetail = ( - await request.get(`${INTEGRATION_CIRCLECI_API_URL}/v2/me/collaborations`, { + await standardRequest.get(`${INTEGRATION_CIRCLECI_API_URL}/v2/me/collaborations`, { headers: { "Circle-Token": accessToken, "Accept-Encoding": "application/json", @@ -1386,7 +1385,7 @@ const syncSecretsCircleCI = async ({ // sync secrets to CircleCI Object.keys(secrets).forEach( async (key) => - await request.post( + await standardRequest.post( `${INTEGRATION_CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar`, { name: key, @@ -1403,7 +1402,7 @@ const syncSecretsCircleCI = async ({ // get secrets from CircleCI const getSecretsRes = ( - await request.get( + await standardRequest.get( `${INTEGRATION_CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar`, { headers: { @@ -1417,7 +1416,7 @@ const syncSecretsCircleCI = async ({ // delete secrets from CircleCI getSecretsRes.forEach(async (sec: any) => { if (!(sec.name in secrets)) { - await request.delete( + await standardRequest.delete( `${INTEGRATION_CIRCLECI_API_URL}/v2/project/${slug}/${integration.app}/envvar/${sec.name}`, { headers: { @@ -1454,7 +1453,7 @@ const syncSecretsTravisCI = async ({ try { // get secrets from travis-ci const getSecretsRes = ( - await request.get( + await standardRequest.get( `${INTEGRATION_TRAVISCI_API_URL}/settings/env_vars?repository_id=${integration.appId}`, { headers: { @@ -1476,7 +1475,7 @@ const syncSecretsTravisCI = async ({ if (!(key in getSecretsRes)) { // case: secret does not exist in travis ci // -> add secret - await request.post( + await standardRequest.post( `${INTEGRATION_TRAVISCI_API_URL}/settings/env_vars?repository_id=${integration.appId}`, { env_var: { @@ -1495,7 +1494,7 @@ const syncSecretsTravisCI = async ({ } else { // case: secret exists in travis ci // -> update/set secret - await request.patch( + await standardRequest.patch( `${INTEGRATION_TRAVISCI_API_URL}/settings/env_vars/${getSecretsRes[key].id}?repository_id=${getSecretsRes[key].repository_id}`, { env_var: { @@ -1517,7 +1516,7 @@ const syncSecretsTravisCI = async ({ for await (const key of Object.keys(getSecretsRes)) { if (!(key in secrets)){ // delete secret - await request.delete( + await standardRequest.delete( `${INTEGRATION_TRAVISCI_API_URL}/settings/env_vars/${getSecretsRes[key].id}?repository_id=${getSecretsRes[key].repository_id}`, { headers: { @@ -1562,7 +1561,7 @@ const syncSecretsGitLab = async ({ // get secrets from gitlab const getSecretsRes: GitLabSecret[] = ( - await request.get( + await standardRequest.get( `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables`, { headers: { @@ -1580,7 +1579,7 @@ const syncSecretsGitLab = async ({ for await (const key of Object.keys(secrets)) { const existingSecret = getSecretsRes.find((s: any) => s.key == key); if (!existingSecret) { - await request.post( + await standardRequest.post( `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables`, { key: key, @@ -1601,7 +1600,7 @@ const syncSecretsGitLab = async ({ } else { // update secret if (secrets[key] !== existingSecret.value) { - await request.put( + await standardRequest.put( `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables/${existingSecret.key}?filter[environment_scope]=${integration.targetEnvironment}`, { ...existingSecret, @@ -1622,7 +1621,7 @@ const syncSecretsGitLab = async ({ // delete secrets for await (const sec of getSecretsRes) { if (!(sec.key in secrets)) { - await request.delete( + await standardRequest.delete( `${INTEGRATION_GITLAB_API_URL}/v4/projects/${integration?.appId}/variables/${sec.key}?filter[environment_scope]=${integration.targetEnvironment}`, { headers: { @@ -1657,7 +1656,7 @@ const syncSecretsSupabase = async ({ accessToken: string; }) => { try { - const { data: getSecretsRes } = await request.get( + const { data: getSecretsRes } = await standardRequest.get( `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, { headers: { @@ -1677,7 +1676,7 @@ const syncSecretsSupabase = async ({ } ); - await request.post( + await standardRequest.post( `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, modifiedFormatForSecretInjection, { @@ -1695,7 +1694,7 @@ const syncSecretsSupabase = async ({ } }); - await request.delete( + await standardRequest.delete( `${INTEGRATION_SUPABASE_API_URL}/v1/projects/${integration.appId}/secrets`, { headers: { diff --git a/backend/src/integrations/teams.ts b/backend/src/integrations/teams.ts index 3bed93c9a..74fc0ca86 100644 --- a/backend/src/integrations/teams.ts +++ b/backend/src/integrations/teams.ts @@ -5,7 +5,7 @@ import { INTEGRATION_GITLAB, INTEGRATION_GITLAB_API_URL } from '../variables'; -import request from '../config/request'; +import { standardRequest } from '../config/request'; interface Team { name: string; @@ -56,7 +56,7 @@ const getTeamsGitLab = async ({ accessToken: string; }) => { let teams: Team[] = []; - const res = (await request.get( + const res = (await standardRequest.get( `${INTEGRATION_GITLAB_API_URL}/v4/groups`, { headers: { diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 76f723df2..7e97b2e32 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -1,8 +1,6 @@ import { Request, Response, NextFunction } from 'express'; import { Types } from 'mongoose'; -import { validateMembership } from '../helpers/membership'; import { validateClientForWorkspace } from '../helpers/workspace'; -import { UnauthorizedRequestError } from '../utils/errors'; type req = 'params' | 'body' | 'query'; @@ -31,7 +29,7 @@ const requireWorkspaceAuth = ({ const environment = locationEnvironment ? req[locationEnvironment]?.environment : undefined; // validate clients - const { membership } = await validateClientForWorkspace({ + const { membership, workspace } = await validateClientForWorkspace({ authData: req.authData, workspaceId: new Types.ObjectId(workspaceId), environment, @@ -43,6 +41,10 @@ const requireWorkspaceAuth = ({ if (membership) { req.membership = membership; } + + if (workspace) { + req.workspace = workspace; + } return next(); }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 2a77c95e9..555f481f8 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -21,6 +21,7 @@ export interface IIntegration { workspace: Types.ObjectId; environment: string; isActive: boolean; + url: string; app: string; appId: string; owner: string; @@ -63,6 +64,11 @@ const integrationSchema = new Schema( type: Boolean, required: true, }, + url: { + // for custom self-hosted integrations (e.g. self-hosted GitHub enterprise) + type: String, + default: null + }, app: { // name of app in provider type: String, diff --git a/cli/packages/cmd/pre-commit-script/pre-commit-without-bang.sh b/cli/packages/cmd/pre-commit-script/pre-commit-without-bang.sh new file mode 100644 index 000000000..e47643cc9 --- /dev/null +++ b/cli/packages/cmd/pre-commit-script/pre-commit-without-bang.sh @@ -0,0 +1,20 @@ + + +# MANAGED BY INFISICAL CLI (Do not modify): START +infisicalScanEnabled=$(git config --bool hooks.infisical-scan) + +if [ "$infisicalScanEnabled" != "false" ]; then + infisical scan git-changes -v --staged + exitCode=$? + if [ $exitCode -eq 1 ]; then + echo "Commit blocked: Infisical scan has uncovered secrets in your git commit" + echo "To disable the Infisical scan precommit hook run the following command:" + echo "" + echo " git config hooks.infisical-scan false" + echo "" + exit 1 + fi +else + echo 'Warning: infisical scan precommit disabled' +fi +# MANAGED BY INFISICAL CLI (Do not modify): END \ No newline at end of file diff --git a/cli/packages/cmd/pre-commit-script/pre-commit.sh b/cli/packages/cmd/pre-commit-script/pre-commit.sh new file mode 100644 index 000000000..f899a1a51 --- /dev/null +++ b/cli/packages/cmd/pre-commit-script/pre-commit.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# MANAGED BY INFISICAL CLI (Do not modify): START +infisicalScanEnabled=$(git config --bool hooks.infisical-scan) + +if [ "$infisicalScanEnabled" != "false" ]; then + infisical scan git-changes -v --staged + exitCode=$? + if [ $exitCode -eq 1 ]; then + echo "Commit blocked: Infisical scan has uncovered secrets in your git commit" + echo "To disable the Infisical scan precommit hook run the following command:" + echo "" + echo " git config hooks.infisical-scan false" + echo "" + exit 1 + fi +else + echo 'Warning: infisical scan precommit disabled' +fi +# MANAGED BY INFISICAL CLI (Do not modify): END \ No newline at end of file diff --git a/cli/packages/cmd/scan.go b/cli/packages/cmd/scan.go index 45796d29d..1226e3319 100644 --- a/cli/packages/cmd/scan.go +++ b/cli/packages/cmd/scan.go @@ -23,7 +23,11 @@ package cmd import ( + _ "embed" + "fmt" + "io/ioutil" "os" + "os/exec" "path/filepath" "strings" "time" @@ -32,6 +36,7 @@ import ( "github.com/Infisical/infisical-merge/detect" "github.com/Infisical/infisical-merge/packages/util" "github.com/Infisical/infisical-merge/report" + "github.com/manifoldco/promptui" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" @@ -45,6 +50,17 @@ order of precedence: 3. (--source/-s)/.infisical-scan.toml If none of the three options are used, then Infisical will use the default scan config` +//go:embed pre-commit-script/pre-commit.sh +var preCommitTemplate []byte + +//go:embed pre-commit-script/pre-commit-without-bang.sh +var preCommitTemplateAppend []byte + +const ( + defaultHooksPath = ".git/hooks/" + preCommitFile = "pre-commit" +) + func init() { // scan flag for only scan command scanCmd.Flags().String("log-opts", "", "git log options") @@ -77,6 +93,9 @@ func init() { // add flags to main scanCmd.AddCommand(scanGitChangesCmd) rootCmd.AddCommand(scanCmd) + + installCmd.Flags().Bool("pre-commit-hook", false, "installs pre commit hook for Git repository") + scanCmd.AddCommand(installCmd) } func initScanConfig(cmd *cobra.Command) { @@ -132,6 +151,50 @@ func initScanConfig(cmd *cobra.Command) { } } +var installCmd = &cobra.Command{ + Use: "install", + Short: "Install scanning scripts and tools. Use --help flag to see all options", + Args: cobra.ExactArgs(0), + Run: func(cmd *cobra.Command, args []string) { + installPrecommit := cmd.Flags().Changed("pre-commit-hook") + if installPrecommit { + hooksPath, err := getHooksPath() + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + if hooksPath != ".git/hooks" { + defaultHookOverride, err := overrideDefaultHooksPath(hooksPath) + if err != nil { + fmt.Printf("Error: %s\n", err) + } + + if defaultHookOverride { + ConfigureGitHooksPath() + + log.Info().Msgf("To switch back previous githooks manager run: git config core.hooksPath %s\n", hooksPath) + return + } else { + log.Warn().Msgf("To automatically configure this hook, you need to switch the path of the Hooks. Alternatively, you can manually configure this hook by setting your pre-commit script to run command [infisical scan git-changes -v --staged].\n") + return + } + } + + err = createOrUpdatePreCommitFile(hooksPath) + if err != nil { + fmt.Printf("Error: %s\n", err) + return + } + + log.Info().Msgf("Pre-commit hook successfully added. Infisical scan should now run on each commit you make\n") + + Telemetry.CaptureEvent("cli-command:install --pre-commit-hook", posthog.NewProperties().Set("version", util.CLI_VERSION)) + + return + } + }} + var scanCmd = &cobra.Command{ Use: "scan", Short: "Scan for leaked secrets in git history, directories, and files", @@ -417,3 +480,107 @@ func FormatDuration(d time.Duration) string { } return d.Round(scale / 100).String() } + +func overrideDefaultHooksPath(managedHook string) (bool, error) { + YES := "Yes" + NO := "No" + + options := []string{YES, NO} + optionsPrompt := promptui.Select{ + Label: fmt.Sprintf("Your hooks path is set to [%s] but needs to be [.git/hooks] for automatic configuration. Would you like to switch? ", managedHook), + Items: options, + Size: 2, + } + + _, selectedOption, err := optionsPrompt.Run() + if err != nil { + return false, err + } + + return selectedOption == YES, err +} + +func ConfigureGitHooksPath() { + cmd := exec.Command("git", "config", "core.hooksPath", ".git/hooks") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + log.Fatal().Msgf("Failed to configure git hooks path: %v", err) + } +} + +// GetGitRoot returns the root directory of the current Git repository. +func GetGitRoot() (string, error) { + cmd := exec.Command("git", "rev-parse", "--show-toplevel") + output, err := cmd.Output() + + if err != nil { + return "", fmt.Errorf("failed to get git root directory: %w", err) + } + + gitRoot := strings.TrimSpace(string(output)) // Remove any trailing newline + return gitRoot, nil +} + +func getHooksPath() (string, error) { + out, err := exec.Command("git", "config", "core.hooksPath").Output() + if err != nil { + if len(out) == 0 { + out = []byte(".git/hooks") // set the default hook + } else { + log.Error().Msgf("Failed to get Git hooks path: %s\nOutput: %s\n", err, out) + } + } + + hooksPath := strings.TrimSpace(string(out)) + return hooksPath, nil +} + +func createOrUpdatePreCommitFile(hooksPath string) error { + // File doesn't exist, create a new one + rootGitRepoPath, err := GetGitRoot() + if err != nil { + return err + } + + filePath := fmt.Sprintf("%s/%s/%s", rootGitRepoPath, hooksPath, preCommitFile) + + _, err = os.Stat(filePath) + if err == nil { + // File already exists, check if it contains the managed comments + content, err := ioutil.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read pre-commit file: %s", err) + } + + if strings.Contains(string(content), "# MANAGED BY INFISICAL CLI (Do not modify): START") && + strings.Contains(string(content), "# MANAGED BY INFISICAL CLI (Do not modify): END") { + return nil + } + + // File already exists, append the template content + file, err := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0755) + if err != nil { + return fmt.Errorf("failed to open pre-commit file: %s", err) + } + + defer file.Close() + + _, err = file.Write(preCommitTemplateAppend) + if err != nil { + return fmt.Errorf("failed to append to pre-commit file: %s", err) + } + + } else if os.IsNotExist(err) { + err = os.WriteFile(filePath, preCommitTemplate, 0755) + if err != nil { + return fmt.Errorf("failed to create pre-commit file: %s", err) + } + } else { + // Error occurred while checking file status + return fmt.Errorf("failed to check pre-commit file status: %s", err) + } + + return nil +} diff --git a/cli/packages/telemetry/telemetry.go b/cli/packages/telemetry/telemetry.go index 18136e909..ffd743457 100644 --- a/cli/packages/telemetry/telemetry.go +++ b/cli/packages/telemetry/telemetry.go @@ -4,6 +4,7 @@ import ( "github.com/Infisical/infisical-merge/packages/util" "github.com/denisbrodbeck/machineid" "github.com/posthog/posthog-go" + "github.com/rs/zerolog/log" ) var POSTHOG_API_KEY_FOR_CLI string @@ -13,11 +14,23 @@ type Telemetry struct { posthogClient posthog.Client } +type NoOpLogger struct{} + +func (NoOpLogger) Logf(format string, args ...interface{}) { + log.Debug().Msgf(format, args...) +} + +func (NoOpLogger) Errorf(format string, args ...interface{}) { + log.Debug().Msgf(format, args...) +} + func NewTelemetry(telemetryIsEnabled bool) *Telemetry { if POSTHOG_API_KEY_FOR_CLI != "" { client, _ := posthog.NewWithConfig( POSTHOG_API_KEY_FOR_CLI, - posthog.Config{}, + posthog.Config{ + Logger: NoOpLogger{}, + }, ) return &Telemetry{isEnabled: telemetryIsEnabled, posthogClient: client} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2ad78bafb..ad5a3ce02 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -37,6 +37,8 @@ services: - MONGO_URL=mongodb://root:example@mongo:27017/?authSource=admin networks: - infisical-dev + extra_hosts: + - "host.docker.internal:host-gateway" frontend: container_name: infisical-dev-frontend diff --git a/docs/cli/commands/scan-install.mdx b/docs/cli/commands/scan-install.mdx new file mode 100644 index 000000000..a0128ef18 --- /dev/null +++ b/docs/cli/commands/scan-install.mdx @@ -0,0 +1,23 @@ +--- +title: "scan install" +description: "Add various scanning tools seamlessly into your development lifecycle" +--- + +```bash +infisical scan install --pre-commit-hook +``` + +## Description +The command `infisical scan install` is designed to incorporate various scanning tools seamlessly into your development lifecycle. +Initially, we are offering users the ability to install a pre-commit hook. This hook conducts an automatic scan for any exposed secrets in your commits before they are pushed. + +### Flags + + ```bash + infisical scan install --pre-commit-hook + ``` + + **Description** + Installs a git pre-commit hook that triggers Infisical to scan your staged changes for any exposed secrets prior to pushing. + + \ No newline at end of file diff --git a/docs/cli/scanning-overview.mdx b/docs/cli/scanning-overview.mdx index 57504b1b4..4ff2683a6 100644 --- a/docs/cli/scanning-overview.mdx +++ b/docs/cli/scanning-overview.mdx @@ -52,6 +52,30 @@ In addition to scanning for past leaks, this new addition also actively aids in +# +# +# Automatically scan changes before you commit + +To lower the risk of committing hardcoded secrets to your code repository, we have designed a custom git pre-commit hook. +This hook scans the changes you're about to commit for any exposed secrets. If any hardcoded secrets are detected, it will block your commit. + +### Install pre-commit hook + +To install this git hook, go into your local git repository and run the following command. + +```bash +infisical scan install --pre-commit-hook +``` + +To disable this hook after installing it, run the command `git config --bool hooks.infisical-scan false` + +### Third party hooks management +If you prefer to manage your pre-commit hook outside of the .git/hooks directory, you can easily accomplish this by adding the following command to your pre-commit script + +```bash +infisical scan git-changes --staged --verbose +``` + # # # Creating a baseline diff --git a/docs/mint.json b/docs/mint.json index 18afe8e2b..0675b1731 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -160,7 +160,8 @@ "group": "infisical scan", "pages": [ "cli/commands/scan", - "cli/commands/scan-git-changes" + "cli/commands/scan-git-changes", + "cli/commands/scan-install" ] } ] diff --git a/docs/self-hosting/deployment-options/standalone-infisical.mdx b/docs/self-hosting/deployment-options/standalone-infisical.mdx index 61cdac62f..8974ae4e6 100644 --- a/docs/self-hosting/deployment-options/standalone-infisical.mdx +++ b/docs/self-hosting/deployment-options/standalone-infisical.mdx @@ -21,6 +21,11 @@ docker pull infisical/infisical:latest The Infisical Docker image requires a .env file to manage environment variables. Create a new file called .env in your preferred location. Add the required environment variables listed below. View [all configurable environment variables](../configuration/envars) + + + Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16` + + Must be a random 16 byte hex string. Can be generated with `openssl rand -hex 16`