mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge branch 'bot'
This commit is contained in:
@@ -47,8 +47,12 @@ SMTP_PASSWORD=
|
||||
|
||||
# Integration
|
||||
# Optional only if integration is used
|
||||
OAUTH_CLIENT_SECRET_HEROKU=
|
||||
OAUTH_TOKEN_URL_HEROKU=
|
||||
CLIENT_ID_HEROKU=
|
||||
CLIENT_ID_VERCEL=
|
||||
CLIENT_ID_NETLIFY=
|
||||
CLIENT_SECRET_HEROKU=
|
||||
CLIENT_SECRET_VERCEL=
|
||||
CLIENT_SECRET_NETLIFY=
|
||||
|
||||
# Sentry (optional) for monitoring errors
|
||||
SENTRY_DSN=
|
||||
|
||||
8
backend/environment.d.ts
vendored
8
backend/environment.d.ts
vendored
@@ -14,8 +14,12 @@ declare global {
|
||||
JWT_SIGNUP_SECRET: string;
|
||||
MONGO_URL: string;
|
||||
NODE_ENV: 'development' | 'staging' | 'testing' | 'production';
|
||||
OAUTH_CLIENT_SECRET_HEROKU: string;
|
||||
OAUTH_TOKEN_URL_HEROKU: string;
|
||||
CLIENT_ID_HEROKU: string;
|
||||
CLIENT_ID_VERCEL: string;
|
||||
CLIENT_ID_NETLIFY: string;
|
||||
CLIENT_SECRET_HEROKU: string;
|
||||
CLIENT_SECRET_VERCEL: string;
|
||||
CLIENT_SECRET_NETLIFY: string;
|
||||
POSTHOG_HOST: string;
|
||||
POSTHOG_PROJECT_API_KEY: string;
|
||||
PRIVATE_KEY: string;
|
||||
|
||||
@@ -10,8 +10,12 @@ const JWT_SIGNUP_LIFETIME = process.env.JWT_SIGNUP_LIFETIME! || '15m';
|
||||
const JWT_SIGNUP_SECRET = process.env.JWT_SIGNUP_SECRET!;
|
||||
const MONGO_URL = process.env.MONGO_URL!;
|
||||
const NODE_ENV = process.env.NODE_ENV! || 'production';
|
||||
const OAUTH_CLIENT_SECRET_HEROKU = process.env.OAUTH_CLIENT_SECRET_HEROKU!;
|
||||
const OAUTH_TOKEN_URL_HEROKU = process.env.OAUTH_TOKEN_URL_HEROKU!;
|
||||
const CLIENT_SECRET_HEROKU = process.env.CLIENT_SECRET_HEROKU!;
|
||||
const CLIENT_ID_HEROKU = process.env.CLIENT_ID_HEROKU!;
|
||||
const CLIENT_ID_VERCEL = process.env.CLIENT_ID_VERCEL!;
|
||||
const CLIENT_ID_NETLIFY = process.env.CLIENT_ID_NETLIFY!;
|
||||
const CLIENT_SECRET_VERCEL = process.env.CLIENT_SECRET_VERCEL!;
|
||||
const CLIENT_SECRET_NETLIFY = process.env.CLIENT_SECRET_NETLIFY!;
|
||||
const POSTHOG_HOST = process.env.POSTHOG_HOST! || 'https://app.posthog.com';
|
||||
const POSTHOG_PROJECT_API_KEY =
|
||||
process.env.POSTHOG_PROJECT_API_KEY! ||
|
||||
@@ -46,8 +50,12 @@ export {
|
||||
JWT_SIGNUP_SECRET,
|
||||
MONGO_URL,
|
||||
NODE_ENV,
|
||||
OAUTH_CLIENT_SECRET_HEROKU,
|
||||
OAUTH_TOKEN_URL_HEROKU,
|
||||
CLIENT_ID_HEROKU,
|
||||
CLIENT_ID_VERCEL,
|
||||
CLIENT_ID_NETLIFY,
|
||||
CLIENT_SECRET_HEROKU,
|
||||
CLIENT_SECRET_VERCEL,
|
||||
CLIENT_SECRET_NETLIFY,
|
||||
POSTHOG_HOST,
|
||||
POSTHOG_PROJECT_API_KEY,
|
||||
PRIVATE_KEY,
|
||||
|
||||
107
backend/src/controllers/botController.ts
Normal file
107
backend/src/controllers/botController.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Request, Response } from 'express';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Bot, BotKey } from '../models';
|
||||
import { createBot } from '../helpers/bot';
|
||||
|
||||
interface BotKey {
|
||||
encryptedKey: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return bot for workspace with id [workspaceId]. If a workspace bot doesn't exist,
|
||||
* then create and return a new bot.
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const getBotByWorkspaceId = async (req: Request, res: Response) => {
|
||||
let bot;
|
||||
try {
|
||||
const { workspaceId } = req.params;
|
||||
|
||||
bot = await Bot.findOne({
|
||||
workspace: workspaceId
|
||||
});
|
||||
|
||||
if (!bot) {
|
||||
// case: bot doesn't exist for workspace with id [workspaceId]
|
||||
// -> create a new bot and return it
|
||||
bot = await createBot({
|
||||
name: 'Infisical Bot',
|
||||
workspaceId
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser({ email: req.user.email });
|
||||
Sentry.captureException(err);
|
||||
return res.status(400).send({
|
||||
message: 'Failed to get bot for workspace'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
bot
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Return bot with id [req.bot._id] with active state set to [isActive].
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const setBotActiveState = async (req: Request, res: Response) => {
|
||||
let bot;
|
||||
try {
|
||||
const { isActive, botKey }: { isActive: boolean, botKey: BotKey } = req.body;
|
||||
|
||||
if (isActive) {
|
||||
// bot state set to active -> share workspace key with bot
|
||||
if (!botKey?.encryptedKey || !botKey?.nonce) {
|
||||
return res.status(400).send({
|
||||
message: 'Failed to set bot state to active - missing bot key'
|
||||
});
|
||||
}
|
||||
|
||||
await BotKey.findOneAndUpdate({
|
||||
workspace: req.bot.workspace
|
||||
}, {
|
||||
encryptedKey: botKey.encryptedKey,
|
||||
nonce: botKey.nonce,
|
||||
sender: req.user._id,
|
||||
bot: req.bot._id,
|
||||
workspace: req.bot.workspace
|
||||
}, {
|
||||
upsert: true,
|
||||
new: true
|
||||
});
|
||||
} else {
|
||||
// case: bot state set to inactive -> delete bot's workspace key
|
||||
await BotKey.deleteOne({
|
||||
bot: req.bot._id
|
||||
});
|
||||
}
|
||||
|
||||
bot = await Bot.findOneAndUpdate({
|
||||
_id: req.bot._id
|
||||
}, {
|
||||
isActive
|
||||
}, {
|
||||
new: true
|
||||
});
|
||||
|
||||
if (!bot) throw new Error('Failed to update bot active state');
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser({ email: req.user.email });
|
||||
Sentry.captureException(err);
|
||||
return res.status(400).send({
|
||||
message: 'Failed to update bot active state'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
bot
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as authController from './authController';
|
||||
import * as botController from './botController';
|
||||
import * as integrationAuthController from './integrationAuthController';
|
||||
import * as integrationController from './integrationController';
|
||||
import * as keyController from './keyController';
|
||||
@@ -16,6 +17,7 @@ import * as workspaceController from './workspaceController';
|
||||
|
||||
export {
|
||||
authController,
|
||||
botController,
|
||||
integrationAuthController,
|
||||
integrationController,
|
||||
keyController,
|
||||
|
||||
@@ -3,69 +3,45 @@ import * as Sentry from '@sentry/node';
|
||||
import axios from 'axios';
|
||||
import { readFileSync } from 'fs';
|
||||
import { IntegrationAuth, Integration } from '../models';
|
||||
import { processOAuthTokenRes } from '../helpers/integrationAuth';
|
||||
import { INTEGRATION_SET, ENV_DEV } from '../variables';
|
||||
import { OAUTH_CLIENT_SECRET_HEROKU, OAUTH_TOKEN_URL_HEROKU } from '../config';
|
||||
import { INTEGRATION_SET, INTEGRATION_OPTIONS, ENV_DEV } from '../variables';
|
||||
import { IntegrationService } from '../services';
|
||||
import { getApps, revokeAccess } from '../integrations';
|
||||
|
||||
export const getIntegrationOptions = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => {
|
||||
return res.status(200).send({
|
||||
integrationOptions: INTEGRATION_OPTIONS
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform OAuth2 code-token exchange as part of integration [integration] for workspace with id [workspaceId]
|
||||
* Note: integration [integration] must be set up compatible/designed for OAuth2
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const integrationAuthOauthExchange = async (
|
||||
export const oAuthExchange = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
let clientSecret;
|
||||
|
||||
const { workspaceId, code, integration } = req.body;
|
||||
|
||||
if (!INTEGRATION_SET.has(integration))
|
||||
throw new Error('Failed to validate integration');
|
||||
|
||||
// use correct client secret
|
||||
switch (integration) {
|
||||
case 'heroku':
|
||||
clientSecret = OAUTH_CLIENT_SECRET_HEROKU;
|
||||
}
|
||||
|
||||
// TODO: unfinished - make compatible with other integration types
|
||||
const res = await axios.post(
|
||||
OAUTH_TOKEN_URL_HEROKU!,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_secret: clientSecret
|
||||
} as any)
|
||||
);
|
||||
|
||||
const integrationAuth = await processOAuthTokenRes({
|
||||
|
||||
await IntegrationService.handleOAuthExchange({
|
||||
workspaceId,
|
||||
integration,
|
||||
res
|
||||
code
|
||||
});
|
||||
|
||||
// create or replace integration
|
||||
const integrationObj = await Integration.findOneAndUpdate(
|
||||
{ workspace: workspaceId, integration },
|
||||
{
|
||||
workspace: workspaceId,
|
||||
environment: ENV_DEV,
|
||||
isActive: false,
|
||||
app: null,
|
||||
integration,
|
||||
integrationAuth: integrationAuth._id
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
return res.status(400).send({
|
||||
message: 'Failed to get OAuth2 token'
|
||||
message: 'Failed to get OAuth2 code-token exchange'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,26 +51,25 @@ export const integrationAuthOauthExchange = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Return list of applications allowed for integration with id [integrationAuthId]
|
||||
* Return list of applications allowed for integration with integration authorization id [integrationAuthId]
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const getIntegrationAuthApps = async (req: Request, res: Response) => {
|
||||
// TODO: unfinished - make compatible with other integration types
|
||||
let apps;
|
||||
try {
|
||||
const res = await axios.get('https://api.heroku.com/apps', {
|
||||
headers: {
|
||||
Accept: 'application/vnd.heroku+json; version=3',
|
||||
Authorization: 'Bearer ' + req.accessToken
|
||||
}
|
||||
apps = await getApps({
|
||||
integrationAuth: req.integrationAuth,
|
||||
accessToken: req.accessToken
|
||||
});
|
||||
|
||||
apps = res.data.map((a: any) => ({
|
||||
name: a.name
|
||||
}));
|
||||
} catch (err) {}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
return res.status(400).send({
|
||||
message: 'Failed to get integration authorization applications'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
apps
|
||||
@@ -108,46 +83,22 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => {
|
||||
* @returns
|
||||
*/
|
||||
export const deleteIntegrationAuth = async (req: Request, res: Response) => {
|
||||
// TODO: unfinished - disable application via Heroku API and make compatible with other integration types
|
||||
try {
|
||||
const { integrationAuthId } = req.params;
|
||||
|
||||
// TODO: disable application via Heroku API; figure out what authorization id is
|
||||
|
||||
const integrations = JSON.parse(
|
||||
readFileSync('./src/json/integrations.json').toString()
|
||||
);
|
||||
|
||||
let authorizationId;
|
||||
switch (req.integrationAuth.integration) {
|
||||
case 'heroku':
|
||||
authorizationId = integrations.heroku.clientId;
|
||||
}
|
||||
|
||||
// not sure what authorizationId is?
|
||||
// // revoke authorization
|
||||
// const res2 = await axios.delete(
|
||||
// `https://api.heroku.com/oauth/authorizations/${authorizationId}`,
|
||||
// {
|
||||
// headers: {
|
||||
// 'Accept': 'application/vnd.heroku+json; version=3',
|
||||
// 'Authorization': 'Bearer ' + req.accessToken
|
||||
// }
|
||||
// }
|
||||
// );
|
||||
|
||||
const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
|
||||
_id: integrationAuthId
|
||||
await revokeAccess({
|
||||
integrationAuth: req.integrationAuth,
|
||||
accessToken: req.accessToken
|
||||
});
|
||||
|
||||
if (deletedIntegrationAuth) {
|
||||
await Integration.deleteMany({
|
||||
integrationAuth: deletedIntegrationAuth._id
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
return res.status(400).send({
|
||||
message: 'Failed to delete integration authorization'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return res.status(200).send({
|
||||
message: 'Successfully deleted integration authorization'
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { readFileSync } from 'fs';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import axios from 'axios';
|
||||
import { Integration } from '../models';
|
||||
import { decryptAsymmetric } from '../utils/crypto';
|
||||
import { decryptSecrets } from '../helpers/secret';
|
||||
import { PRIVATE_KEY } from '../config';
|
||||
import { Integration, Bot, BotKey } from '../models';
|
||||
import { EventService } from '../services';
|
||||
import { eventPushSecrets } from '../events';
|
||||
|
||||
interface Key {
|
||||
encryptedKey: string;
|
||||
@@ -24,104 +22,58 @@ interface PushSecret {
|
||||
type: 'shared' | 'personal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return list of all available integrations on Infisical
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const getIntegrations = async (req: Request, res: Response) => {
|
||||
let integrations;
|
||||
try {
|
||||
integrations = JSON.parse(
|
||||
readFileSync('./src/json/integrations.json').toString()
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
return res.status(400).send({
|
||||
message: 'Failed to get integrations'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
integrations
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync secrets [secrets] to integration with id [integrationId]
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const syncIntegration = async (req: Request, res: Response) => {
|
||||
// TODO: unfinished - make more versatile to accomodate for other integrations
|
||||
try {
|
||||
const { key, secrets }: { key: Key; secrets: PushSecret[] } = req.body;
|
||||
const symmetricKey = decryptAsymmetric({
|
||||
ciphertext: key.encryptedKey,
|
||||
nonce: key.nonce,
|
||||
publicKey: req.user.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
// decrypt secrets with symmetric key
|
||||
const content = decryptSecrets({
|
||||
secrets,
|
||||
key: symmetricKey,
|
||||
format: 'object'
|
||||
});
|
||||
|
||||
// TODO: make integration work for other integrations as well
|
||||
const res = await axios.patch(
|
||||
`https://api.heroku.com/apps/${req.integration.app}/config-vars`,
|
||||
content,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.heroku+json; version=3',
|
||||
Authorization: 'Bearer ' + req.accessToken
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
return res.status(400).send({
|
||||
message: 'Failed to sync secrets with integration'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
message: 'Successfully synced secrets with integration'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Change environment or name of integration with id [integrationId]
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
export const modifyIntegration = async (req: Request, res: Response) => {
|
||||
export const updateIntegration = async (req: Request, res: Response) => {
|
||||
let integration;
|
||||
|
||||
// TODO: add integration-specific validation to ensure that each
|
||||
// integration has the correct fields populated in [Integration]
|
||||
|
||||
try {
|
||||
const { update } = req.body;
|
||||
|
||||
const {
|
||||
app,
|
||||
environment,
|
||||
isActive,
|
||||
target, // vercel-specific integration param
|
||||
context, // netlify-specific integration param
|
||||
siteId // netlify-specific integration param
|
||||
} = req.body;
|
||||
|
||||
integration = await Integration.findOneAndUpdate(
|
||||
{
|
||||
_id: req.integration._id
|
||||
},
|
||||
update,
|
||||
{
|
||||
environment,
|
||||
isActive,
|
||||
app,
|
||||
target,
|
||||
context,
|
||||
siteId
|
||||
},
|
||||
{
|
||||
new: true
|
||||
}
|
||||
);
|
||||
|
||||
if (integration) {
|
||||
// trigger event - push secrets
|
||||
EventService.handleEvent({
|
||||
event: eventPushSecrets({
|
||||
workspaceId: integration.workspace.toString()
|
||||
})
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser({ email: req.user.email });
|
||||
Sentry.captureException(err);
|
||||
return res.status(400).send({
|
||||
message: 'Failed to modify integration'
|
||||
message: 'Failed to update integration'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,7 +83,8 @@ export const modifyIntegration = async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete integration with id [integrationId]
|
||||
* Delete integration with id [integrationId] and deactivate bot if there are
|
||||
* no integrations left
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
@@ -144,6 +97,29 @@ export const deleteIntegration = async (req: Request, res: Response) => {
|
||||
deletedIntegration = await Integration.findOneAndDelete({
|
||||
_id: integrationId
|
||||
});
|
||||
|
||||
if (!deletedIntegration) throw new Error('Failed to find integration');
|
||||
|
||||
const integrations = await Integration.find({
|
||||
workspace: deletedIntegration.workspace
|
||||
});
|
||||
|
||||
if (integrations.length === 0) {
|
||||
// case: no integrations left, deactivate bot
|
||||
const bot = await Bot.findOneAndUpdate({
|
||||
workspace: deletedIntegration.workspace
|
||||
}, {
|
||||
isActive: false
|
||||
}, {
|
||||
new: true
|
||||
});
|
||||
|
||||
if (bot) {
|
||||
await BotKey.deleteOne({
|
||||
bot: bot._id
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser({ email: req.user.email });
|
||||
Sentry.captureException(err);
|
||||
|
||||
@@ -17,16 +17,6 @@ export const uploadKey = async (req: Request, res: Response) => {
|
||||
const { workspaceId } = req.params;
|
||||
const { key } = req.body;
|
||||
|
||||
// validate membership of sender
|
||||
const senderMembership = await findMembership({
|
||||
user: req.user._id,
|
||||
workspace: workspaceId
|
||||
});
|
||||
|
||||
if (!senderMembership) {
|
||||
throw new Error('Failed sender membership validation for workspace');
|
||||
}
|
||||
|
||||
// validate membership of receiver
|
||||
const receiverMembership = await findMembership({
|
||||
user: key.userId,
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
reformatPullSecrets
|
||||
} from '../helpers/secret';
|
||||
import { pushKeys } from '../helpers/key';
|
||||
import { eventPushSecrets } from '../events';
|
||||
import { EventService } from '../services';
|
||||
import { ENV_SET } from '../variables';
|
||||
|
||||
import { postHogClient } from '../services';
|
||||
|
||||
interface PushSecret {
|
||||
@@ -60,7 +61,8 @@ export const pushSecrets = async (req: Request, res: Response) => {
|
||||
workspaceId,
|
||||
keys
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (postHogClient) {
|
||||
postHogClient.capture({
|
||||
event: 'secrets pushed',
|
||||
@@ -74,6 +76,13 @@ export const pushSecrets = async (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
// trigger event - push secrets
|
||||
EventService.handleEvent({
|
||||
event: eventPushSecrets({
|
||||
workspaceId
|
||||
})
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser({ email: req.user.email });
|
||||
Sentry.captureException(err);
|
||||
@@ -192,7 +201,7 @@ export const pullSecretsServiceToken = async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
if (postHogClient) {
|
||||
// capture secrets pushed event in production
|
||||
// capture secrets pulled event in production
|
||||
postHogClient.capture({
|
||||
distinctId: req.serviceToken.user.email,
|
||||
event: 'secrets pulled',
|
||||
|
||||
5
backend/src/events/index.ts
Normal file
5
backend/src/events/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { eventPushSecrets } from "./secret"
|
||||
|
||||
export {
|
||||
eventPushSecrets
|
||||
}
|
||||
37
backend/src/events/secret.ts
Normal file
37
backend/src/events/secret.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { EVENT_PUSH_SECRETS } from '../variables';
|
||||
|
||||
interface PushSecret {
|
||||
ciphertextKey: string;
|
||||
ivKey: string;
|
||||
tagKey: string;
|
||||
hashKey: string;
|
||||
ciphertextValue: string;
|
||||
ivValue: string;
|
||||
tagValue: string;
|
||||
hashValue: string;
|
||||
type: 'shared' | 'personal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return event for pushing secrets
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace to push secrets to
|
||||
* @returns
|
||||
*/
|
||||
const eventPushSecrets = ({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
return ({
|
||||
name: EVENT_PUSH_SECRETS,
|
||||
workspaceId,
|
||||
payload: {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
eventPushSecrets
|
||||
}
|
||||
230
backend/src/helpers/bot.ts
Normal file
230
backend/src/helpers/bot.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
Bot,
|
||||
BotKey,
|
||||
Secret,
|
||||
ISecret,
|
||||
IUser
|
||||
} from '../models';
|
||||
import {
|
||||
generateKeyPair,
|
||||
encryptSymmetric,
|
||||
decryptSymmetric,
|
||||
decryptAsymmetric
|
||||
} from '../utils/crypto';
|
||||
import { decryptSecrets } from '../helpers/secret';
|
||||
import { ENCRYPTION_KEY } from '../config';
|
||||
import { SECRET_SHARED } from '../variables';
|
||||
|
||||
/**
|
||||
* Create an inactive bot with name [name] for workspace with id [workspaceId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.name - name of bot
|
||||
* @param {String} obj.workspaceId - id of workspace that bot belongs to
|
||||
*/
|
||||
const createBot = async ({
|
||||
name,
|
||||
workspaceId,
|
||||
}: {
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
let bot;
|
||||
try {
|
||||
const { publicKey, privateKey } = generateKeyPair();
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext: privateKey,
|
||||
key: ENCRYPTION_KEY
|
||||
});
|
||||
|
||||
bot = await new Bot({
|
||||
name,
|
||||
workspace: workspaceId,
|
||||
isActive: false,
|
||||
publicKey,
|
||||
encryptedPrivateKey: ciphertext,
|
||||
iv,
|
||||
tag
|
||||
}).save();
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to create bot');
|
||||
}
|
||||
|
||||
return bot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return decrypted secrets for workspace with id [workspaceId]
|
||||
* and [environment] using bot
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @param {String} obj.environment - environment
|
||||
*/
|
||||
const getSecretsHelper = async ({
|
||||
workspaceId,
|
||||
environment
|
||||
}: {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
}) => {
|
||||
let content = {} as any;
|
||||
try {
|
||||
const key = await getKey({ workspaceId });
|
||||
const secrets = await Secret.find({
|
||||
workspaceId,
|
||||
environment,
|
||||
type: SECRET_SHARED
|
||||
});
|
||||
|
||||
secrets.forEach((secret: ISecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: secret.secretKeyCiphertext,
|
||||
iv: secret.secretKeyIV,
|
||||
tag: secret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: secret.secretValueCiphertext,
|
||||
iv: secret.secretValueIV,
|
||||
tag: secret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
content[secretKey] = secretValue;
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get secrets');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return bot's copy of the workspace key for workspace
|
||||
* with id [workspaceId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @returns {String} key - decrypted workspace key
|
||||
*/
|
||||
const getKey = async ({ workspaceId }: { workspaceId: string }) => {
|
||||
let key;
|
||||
try {
|
||||
const botKey = await BotKey.findOne({
|
||||
workspace: workspaceId
|
||||
}).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');
|
||||
|
||||
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: ENCRYPTION_KEY
|
||||
});
|
||||
|
||||
key = decryptAsymmetric({
|
||||
ciphertext: botKey.encryptedKey,
|
||||
nonce: botKey.nonce,
|
||||
publicKey: botKey.sender.publicKey as string,
|
||||
privateKey: privateKeyBot
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get workspace key');
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return symmetrically encrypted [plaintext] using the
|
||||
* key for workspace with id [workspaceId]
|
||||
* @param {Object} obj1
|
||||
* @param {String} obj1.workspaceId - id of workspace
|
||||
* @param {String} obj1.plaintext - plaintext to encrypt
|
||||
*/
|
||||
const encryptSymmetricHelper = async ({
|
||||
workspaceId,
|
||||
plaintext
|
||||
}: {
|
||||
workspaceId: string;
|
||||
plaintext: string;
|
||||
}) => {
|
||||
|
||||
try {
|
||||
const key = await getKey({ workspaceId });
|
||||
const { ciphertext, iv, tag } = encryptSymmetric({
|
||||
plaintext,
|
||||
key
|
||||
});
|
||||
|
||||
return ({
|
||||
ciphertext,
|
||||
iv,
|
||||
tag
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to perform symmetric encryption with bot');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Return symmetrically decrypted [ciphertext] using the
|
||||
* key for workspace with id [workspaceId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @param {String} obj.ciphertext - ciphertext to decrypt
|
||||
* @param {String} obj.iv - iv
|
||||
* @param {String} obj.tag - tag
|
||||
*/
|
||||
const decryptSymmetricHelper = async ({
|
||||
workspaceId,
|
||||
ciphertext,
|
||||
iv,
|
||||
tag
|
||||
}: {
|
||||
workspaceId: string;
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}) => {
|
||||
let plaintext;
|
||||
try {
|
||||
const key = await getKey({ workspaceId });
|
||||
const plaintext = decryptSymmetric({
|
||||
ciphertext,
|
||||
iv,
|
||||
tag,
|
||||
key
|
||||
});
|
||||
|
||||
return plaintext;
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to perform symmetric decryption with bot');
|
||||
}
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
export {
|
||||
createBot,
|
||||
getSecretsHelper,
|
||||
encryptSymmetricHelper,
|
||||
decryptSymmetricHelper
|
||||
}
|
||||
51
backend/src/helpers/event.ts
Normal file
51
backend/src/helpers/event.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Bot, IBot } from '../models';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { EVENT_PUSH_SECRETS } from '../variables';
|
||||
import { IntegrationService } from '../services';
|
||||
|
||||
interface Event {
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle event [event]
|
||||
* @param {Object} obj
|
||||
* @param {Event} obj.event - an event
|
||||
* @param {String} obj.event.name - name of event
|
||||
* @param {String} obj.event.workspaceId - id of workspace that event is part of
|
||||
* @param {Object} obj.event.payload - payload of event (depends on event)
|
||||
*/
|
||||
const handleEventHelper = async ({
|
||||
event
|
||||
}: {
|
||||
event: Event;
|
||||
}) => {
|
||||
const { workspaceId } = event;
|
||||
|
||||
// TODO: moduralize bot check into separate function
|
||||
const bot = await Bot.findOne({
|
||||
workspace: workspaceId,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!bot) return;
|
||||
|
||||
try {
|
||||
switch (event.name) {
|
||||
case EVENT_PUSH_SECRETS:
|
||||
IntegrationService.syncIntegrations({
|
||||
workspaceId
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
handleEventHelper
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
Bot,
|
||||
Integration,
|
||||
IIntegration,
|
||||
IntegrationAuth,
|
||||
IIntegrationAuth
|
||||
} from '../models';
|
||||
import { exchangeCode, exchangeRefresh, syncSecrets } from '../integrations';
|
||||
import { BotService, IntegrationService } from '../services';
|
||||
import {
|
||||
ENV_DEV,
|
||||
EVENT_PUSH_SECRETS,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY
|
||||
} from '../variables';
|
||||
|
||||
interface Update {
|
||||
workspace: string;
|
||||
integration: string;
|
||||
teamId?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration
|
||||
* named [integration]
|
||||
* - Store integration access and refresh tokens returned from the OAuth2 code-token exchange
|
||||
* - Add placeholder inactive integration
|
||||
* - Create bot sequence for integration
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @param {String} obj.integration - name of integration
|
||||
* @param {String} obj.code - code
|
||||
*/
|
||||
const handleOAuthExchangeHelper = async ({
|
||||
workspaceId,
|
||||
integration,
|
||||
code
|
||||
}: {
|
||||
workspaceId: string;
|
||||
integration: string;
|
||||
code: string;
|
||||
}) => {
|
||||
let action;
|
||||
let integrationAuth;
|
||||
try {
|
||||
const bot = await Bot.findOne({
|
||||
workspace: workspaceId,
|
||||
isActive: true
|
||||
});
|
||||
|
||||
if (!bot) throw new Error('Bot must be enabled for OAuth2 code-token exchange');
|
||||
|
||||
// exchange code for access and refresh tokens
|
||||
let res = await exchangeCode({
|
||||
integration,
|
||||
code
|
||||
});
|
||||
|
||||
let update: Update = {
|
||||
workspace: workspaceId,
|
||||
integration
|
||||
}
|
||||
|
||||
switch (integration) {
|
||||
case INTEGRATION_VERCEL:
|
||||
update.teamId = res.teamId;
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
update.accountId = res.accountId;
|
||||
break;
|
||||
}
|
||||
|
||||
integrationAuth = await IntegrationAuth.findOneAndUpdate({
|
||||
workspace: workspaceId,
|
||||
integration
|
||||
}, update, {
|
||||
new: true,
|
||||
upsert: true
|
||||
});
|
||||
|
||||
if (res.refreshToken) {
|
||||
// case: refresh token returned from exchange
|
||||
// set integration auth refresh token
|
||||
await setIntegrationAuthRefreshHelper({
|
||||
integrationAuthId: integrationAuth._id.toString(),
|
||||
refreshToken: res.refreshToken
|
||||
});
|
||||
}
|
||||
|
||||
if (res.accessToken) {
|
||||
// case: access token returned from exchange
|
||||
// set integration auth access token
|
||||
await setIntegrationAuthAccessHelper({
|
||||
integrationAuthId: integrationAuth._id.toString(),
|
||||
accessToken: res.accessToken,
|
||||
accessExpiresAt: res.accessExpiresAt
|
||||
});
|
||||
}
|
||||
|
||||
// initialize new integration after exchange
|
||||
await new Integration({
|
||||
workspace: workspaceId,
|
||||
environment: ENV_DEV,
|
||||
isActive: false,
|
||||
app: null,
|
||||
integration,
|
||||
integrationAuth: integrationAuth._id
|
||||
}).save();
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to handle OAuth2 code-token exchange')
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Sync/push environment variables in workspace with id [workspaceId] to
|
||||
* all active integrations for that workspace
|
||||
* @param {Object} obj
|
||||
* @param {Object} obj.workspaceId - id of workspace
|
||||
*/
|
||||
const syncIntegrationsHelper = async ({
|
||||
workspaceId
|
||||
}: {
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
let integrations;
|
||||
try {
|
||||
|
||||
integrations = await Integration.find({
|
||||
workspace: workspaceId,
|
||||
isActive: true,
|
||||
app: { $ne: null }
|
||||
});
|
||||
|
||||
// for each workspace integration, sync/push secrets
|
||||
// to that integration
|
||||
for await (const integration of integrations) {
|
||||
// get workspace, environment (shared) secrets
|
||||
const secrets = await BotService.getSecrets({
|
||||
workspaceId: integration.workspace.toString(),
|
||||
environment: integration.environment
|
||||
});
|
||||
|
||||
const integrationAuth = await IntegrationAuth.findById(integration.integrationAuth);
|
||||
if (!integrationAuth) throw new Error('Failed to find integration auth');
|
||||
|
||||
// get integration auth access token
|
||||
const accessToken = await getIntegrationAuthAccessHelper({
|
||||
integrationAuthId: integration.integrationAuth.toString()
|
||||
});
|
||||
|
||||
// sync secrets to integration
|
||||
await syncSecrets({
|
||||
integration,
|
||||
integrationAuth,
|
||||
secrets,
|
||||
accessToken
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to sync secrets to integrations');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return decrypted refresh token using the bot's copy
|
||||
* of the workspace key for workspace belonging to integration auth
|
||||
* with id [integrationAuthId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationAuthId - id of integration auth
|
||||
* @param {String} refreshToken - decrypted refresh token
|
||||
*/
|
||||
const getIntegrationAuthRefreshHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => {
|
||||
let refreshToken;
|
||||
try {
|
||||
const integrationAuth = await IntegrationAuth
|
||||
.findById(integrationAuthId)
|
||||
.select('+refreshCiphertext +refreshIV +refreshTag');
|
||||
|
||||
if (!integrationAuth) throw new Error('Failed to find integration auth');
|
||||
|
||||
refreshToken = await BotService.decryptSymmetric({
|
||||
workspaceId: integrationAuth.workspace.toString(),
|
||||
ciphertext: integrationAuth.refreshCiphertext as string,
|
||||
iv: integrationAuth.refreshIV as string,
|
||||
tag: integrationAuth.refreshTag as string
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get integration refresh token');
|
||||
}
|
||||
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return decrypted access token using the bot's copy
|
||||
* of the workspace key for workspace belonging to integration auth
|
||||
* with id [integrationAuthId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationAuthId - id of integration auth
|
||||
* @returns {String} accessToken - decrypted access token
|
||||
*/
|
||||
const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrationAuthId: string }) => {
|
||||
let accessToken;
|
||||
try {
|
||||
const integrationAuth = await IntegrationAuth
|
||||
.findById(integrationAuthId)
|
||||
.select('workspace integration +accessCiphertext +accessIV +accessTag +accessExpiresAt + refreshCiphertext');
|
||||
|
||||
if (!integrationAuth) throw new Error('Failed to find integration auth');
|
||||
|
||||
accessToken = await BotService.decryptSymmetric({
|
||||
workspaceId: integrationAuth.workspace.toString(),
|
||||
ciphertext: integrationAuth.accessCiphertext as string,
|
||||
iv: integrationAuth.accessIV as string,
|
||||
tag: integrationAuth.accessTag as string
|
||||
});
|
||||
|
||||
if (integrationAuth?.accessExpiresAt && integrationAuth?.refreshCiphertext) {
|
||||
// there is a access token expiration date
|
||||
// and refresh token to exchange with the OAuth2 server
|
||||
|
||||
if (integrationAuth.accessExpiresAt < new Date()) {
|
||||
// access token is expired
|
||||
const refreshToken = await getIntegrationAuthRefreshHelper({ integrationAuthId });
|
||||
accessToken = await exchangeRefresh({
|
||||
integration: integrationAuth.integration,
|
||||
refreshToken
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get integration access token');
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt refresh token [refreshToken] using the bot's copy
|
||||
* of the workspace key for workspace belonging to integration auth
|
||||
* with id [integrationAuthId] and store it
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationAuthId - id of integration auth
|
||||
* @param {String} obj.refreshToken - refresh token
|
||||
*/
|
||||
const setIntegrationAuthRefreshHelper = async ({
|
||||
integrationAuthId,
|
||||
refreshToken
|
||||
}: {
|
||||
integrationAuthId: string;
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
|
||||
let integrationAuth;
|
||||
try {
|
||||
integrationAuth = await IntegrationAuth
|
||||
.findById(integrationAuthId);
|
||||
|
||||
if (!integrationAuth) throw new Error('Failed to find integration auth');
|
||||
|
||||
const obj = await BotService.encryptSymmetric({
|
||||
workspaceId: integrationAuth.workspace.toString(),
|
||||
plaintext: refreshToken
|
||||
});
|
||||
|
||||
integrationAuth = await IntegrationAuth.findOneAndUpdate({
|
||||
_id: integrationAuthId
|
||||
}, {
|
||||
refreshCiphertext: obj.ciphertext,
|
||||
refreshIV: obj.iv,
|
||||
refreshTag: obj.tag
|
||||
}, {
|
||||
new: true
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to set integration auth refresh token');
|
||||
}
|
||||
|
||||
return integrationAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt access token [accessToken] using the bot's copy
|
||||
* of the workspace key for workspace belonging to integration auth
|
||||
* with id [integrationAuthId] and store it along with [accessExpiresAt]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationAuthId - id of integration auth
|
||||
* @param {String} obj.accessToken - access token
|
||||
* @param {Date} obj.accessExpiresAt - expiration date of access token
|
||||
*/
|
||||
const setIntegrationAuthAccessHelper = async ({
|
||||
integrationAuthId,
|
||||
accessToken,
|
||||
accessExpiresAt
|
||||
}: {
|
||||
integrationAuthId: string;
|
||||
accessToken: string;
|
||||
accessExpiresAt: Date;
|
||||
}) => {
|
||||
let integrationAuth;
|
||||
try {
|
||||
integrationAuth = await IntegrationAuth.findById(integrationAuthId);
|
||||
|
||||
if (!integrationAuth) throw new Error('Failed to find integration auth');
|
||||
|
||||
const obj = await BotService.encryptSymmetric({
|
||||
workspaceId: integrationAuth.workspace.toString(),
|
||||
plaintext: accessToken
|
||||
});
|
||||
|
||||
integrationAuth = await IntegrationAuth.findOneAndUpdate({
|
||||
_id: integrationAuthId
|
||||
}, {
|
||||
accessCiphertext: obj.ciphertext,
|
||||
accessIV: obj.iv,
|
||||
accessTag: obj.tag,
|
||||
accessExpiresAt
|
||||
}, {
|
||||
new: true
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to save integration auth access token');
|
||||
}
|
||||
|
||||
return integrationAuth;
|
||||
}
|
||||
|
||||
export {
|
||||
handleOAuthExchangeHelper,
|
||||
syncIntegrationsHelper,
|
||||
getIntegrationAuthRefreshHelper,
|
||||
getIntegrationAuthAccessHelper,
|
||||
setIntegrationAuthRefreshHelper,
|
||||
setIntegrationAuthAccessHelper
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import axios from 'axios';
|
||||
import { IntegrationAuth } from '../models';
|
||||
import { encryptSymmetric, decryptSymmetric } from '../utils/crypto';
|
||||
import { IIntegrationAuth } from '../models';
|
||||
import {
|
||||
ENCRYPTION_KEY,
|
||||
OAUTH_CLIENT_SECRET_HEROKU,
|
||||
OAUTH_TOKEN_URL_HEROKU
|
||||
} from '../config';
|
||||
|
||||
/**
|
||||
* Process token exchange and refresh responses from respective OAuth2 authorization servers by
|
||||
* encrypting access and refresh tokens, computing new access token expiration times [accessExpiresAt],
|
||||
* and upserting them into the DB for workspace with id [workspaceId] and integration [integration].
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @param {String} obj.integration - name of integration (e.g. heroku)
|
||||
* @param {Object} obj.res - response from OAuth2 authorization server
|
||||
*/
|
||||
const processOAuthTokenRes = async ({
|
||||
workspaceId,
|
||||
integration,
|
||||
res
|
||||
}: {
|
||||
workspaceId: string;
|
||||
integration: string;
|
||||
res: any;
|
||||
}): Promise<IIntegrationAuth> => {
|
||||
let integrationAuth;
|
||||
try {
|
||||
// encrypt refresh + access tokens
|
||||
const {
|
||||
ciphertext: refreshCiphertext,
|
||||
iv: refreshIV,
|
||||
tag: refreshTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: res.data.refresh_token,
|
||||
key: ENCRYPTION_KEY
|
||||
});
|
||||
|
||||
const {
|
||||
ciphertext: accessCiphertext,
|
||||
iv: accessIV,
|
||||
tag: accessTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: res.data.access_token,
|
||||
key: ENCRYPTION_KEY
|
||||
});
|
||||
|
||||
// compute access token expiration date
|
||||
const accessExpiresAt = new Date();
|
||||
accessExpiresAt.setSeconds(
|
||||
accessExpiresAt.getSeconds() + res.data.expires_in
|
||||
);
|
||||
|
||||
// create or replace integration authorization with encrypted tokens
|
||||
// and access token expiration date
|
||||
integrationAuth = await IntegrationAuth.findOneAndUpdate(
|
||||
{ workspace: workspaceId, integration },
|
||||
{
|
||||
workspace: workspaceId,
|
||||
integration,
|
||||
refreshCiphertext,
|
||||
refreshIV,
|
||||
refreshTag,
|
||||
accessCiphertext,
|
||||
accessIV,
|
||||
accessTag,
|
||||
accessExpiresAt
|
||||
},
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error(
|
||||
'Failed to process OAuth2 authorization server token response'
|
||||
);
|
||||
}
|
||||
|
||||
return integrationAuth;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return access token for integration either by decrypting a non-expired access token [accessCiphertext] on
|
||||
* the integration authorization document or by requesting a new one by decrypting and exchanging the
|
||||
* refresh token [refreshCiphertext] with the respective OAuth2 authorization server.
|
||||
* @param {Object} obj
|
||||
* @param {IIntegrationAuth} obj.integrationAuth - an integration authorization document
|
||||
* @returns {String} access token - new access token
|
||||
*/
|
||||
const getOAuthAccessToken = async ({
|
||||
integrationAuth
|
||||
}: {
|
||||
integrationAuth: IIntegrationAuth;
|
||||
}) => {
|
||||
let accessToken;
|
||||
try {
|
||||
const {
|
||||
refreshCiphertext,
|
||||
refreshIV,
|
||||
refreshTag,
|
||||
accessCiphertext,
|
||||
accessIV,
|
||||
accessTag,
|
||||
accessExpiresAt
|
||||
} = integrationAuth;
|
||||
|
||||
if (
|
||||
refreshCiphertext &&
|
||||
refreshIV &&
|
||||
refreshTag &&
|
||||
accessCiphertext &&
|
||||
accessIV &&
|
||||
accessTag &&
|
||||
accessExpiresAt
|
||||
) {
|
||||
if (accessExpiresAt < new Date()) {
|
||||
// case: access token expired
|
||||
// TODO: fetch another access token
|
||||
|
||||
let clientSecret;
|
||||
switch (integrationAuth.integration) {
|
||||
case 'heroku':
|
||||
clientSecret = OAUTH_CLIENT_SECRET_HEROKU;
|
||||
}
|
||||
|
||||
// record new access token and refresh token
|
||||
// encrypt refresh + access tokens
|
||||
const refreshToken = decryptSymmetric({
|
||||
ciphertext: refreshCiphertext,
|
||||
iv: refreshIV,
|
||||
tag: refreshTag,
|
||||
key: ENCRYPTION_KEY
|
||||
});
|
||||
|
||||
// TODO: make route compatible with other integration types
|
||||
const res = await axios.post(
|
||||
OAUTH_TOKEN_URL_HEROKU, // maybe shouldn't be a config variable?
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_secret: clientSecret
|
||||
} as any)
|
||||
);
|
||||
|
||||
accessToken = res.data.access_token;
|
||||
|
||||
await processOAuthTokenRes({
|
||||
workspaceId: integrationAuth.workspace.toString(),
|
||||
integration: integrationAuth.integration,
|
||||
res
|
||||
});
|
||||
} else {
|
||||
// case: access token still works
|
||||
accessToken = decryptSymmetric({
|
||||
ciphertext: accessCiphertext,
|
||||
iv: accessIV,
|
||||
tag: accessTag,
|
||||
key: ENCRYPTION_KEY
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get OAuth2 access token');
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
};
|
||||
|
||||
export { processOAuthTokenRes, getOAuthAccessToken };
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Membership, Key } from '../models';
|
||||
|
||||
/**
|
||||
* Validate that user with id [userId] is a member of workspace with id [workspaceId]
|
||||
* and has at least one of the roles in [acceptedRoles] and statuses in [acceptedStatuses]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.userId - id of user to validate
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
*/
|
||||
const validateMembership = async ({
|
||||
userId,
|
||||
workspaceId,
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
acceptedRoles: string[];
|
||||
acceptedStatuses: string[];
|
||||
}) => {
|
||||
|
||||
let membership;
|
||||
try {
|
||||
membership = await Membership.findOne({
|
||||
user: userId,
|
||||
workspace: workspaceId
|
||||
});
|
||||
|
||||
if (!membership) throw new Error('Failed to find membership');
|
||||
|
||||
if (!acceptedRoles.includes(membership.role)) {
|
||||
throw new Error('Failed to validate membership role');
|
||||
}
|
||||
|
||||
if (!acceptedStatuses.includes(membership.status)) {
|
||||
throw new Error('Failed to validate membership status');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to validate membership');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return membership matching criteria specified in query [queryObj]
|
||||
* @param {Object} queryObj - query object
|
||||
@@ -97,4 +142,9 @@ const deleteMembership = async ({ membershipId }: { membershipId: string }) => {
|
||||
return deletedMembership;
|
||||
};
|
||||
|
||||
export { addMemberships, findMembership, deleteMembership };
|
||||
export {
|
||||
validateMembership,
|
||||
addMemberships,
|
||||
findMembership,
|
||||
deleteMembership
|
||||
};
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
Workspace,
|
||||
Bot,
|
||||
Membership,
|
||||
Key,
|
||||
Secret
|
||||
} from '../models';
|
||||
import { createBot } from '../helpers/bot';
|
||||
|
||||
/**
|
||||
* Create a workspace with name [name] in organization with id [organizationId]
|
||||
* and a bot for it.
|
||||
* @param {String} name - name of workspace to create.
|
||||
* @param {String} organizationId - id of organization to create workspace in
|
||||
* @param {Object} workspace - new workspace
|
||||
@@ -21,10 +24,16 @@ const createWorkspace = async ({
|
||||
}) => {
|
||||
let workspace;
|
||||
try {
|
||||
// create workspace
|
||||
workspace = await new Workspace({
|
||||
name,
|
||||
organization: organizationId
|
||||
}).save();
|
||||
|
||||
const bot = await createBot({
|
||||
name: 'Infisical Bot',
|
||||
workspaceId: workspace._id.toString()
|
||||
});
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
@@ -43,6 +52,9 @@ const createWorkspace = async ({
|
||||
const deleteWorkspace = async ({ id }: { id: string }) => {
|
||||
try {
|
||||
await Workspace.deleteOne({ _id: id });
|
||||
await Bot.deleteOne({
|
||||
workspace: id
|
||||
});
|
||||
await Membership.deleteMany({
|
||||
workspace: id
|
||||
});
|
||||
|
||||
@@ -23,22 +23,23 @@ Sentry.init({
|
||||
});
|
||||
|
||||
import {
|
||||
signup as signupRouter,
|
||||
auth as authRouter,
|
||||
organization as organizationRouter,
|
||||
workspace as workspaceRouter,
|
||||
membershipOrg as membershipOrgRouter,
|
||||
membership as membershipRouter,
|
||||
key as keyRouter,
|
||||
inviteOrg as inviteOrgRouter,
|
||||
user as userRouter,
|
||||
userAction as userActionRouter,
|
||||
secret as secretRouter,
|
||||
serviceToken as serviceTokenRouter,
|
||||
password as passwordRouter,
|
||||
stripe as stripeRouter,
|
||||
integration as integrationRouter,
|
||||
integrationAuth as integrationAuthRouter
|
||||
signup as signupRouter,
|
||||
auth as authRouter,
|
||||
bot as botRouter,
|
||||
organization as organizationRouter,
|
||||
workspace as workspaceRouter,
|
||||
membershipOrg as membershipOrgRouter,
|
||||
membership as membershipRouter,
|
||||
key as keyRouter,
|
||||
inviteOrg as inviteOrgRouter,
|
||||
user as userRouter,
|
||||
userAction as userActionRouter,
|
||||
secret as secretRouter,
|
||||
serviceToken as serviceTokenRouter,
|
||||
password as passwordRouter,
|
||||
stripe as stripeRouter,
|
||||
integration as integrationRouter,
|
||||
integrationAuth as integrationAuthRouter
|
||||
} from './routes';
|
||||
|
||||
const connectWithRetry = () => {
|
||||
@@ -78,6 +79,7 @@ app.use(express.json());
|
||||
// routers
|
||||
app.use('/api/v1/signup', signupRouter);
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
app.use('/api/v1/bot', botRouter);
|
||||
app.use('/api/v1/user', userRouter);
|
||||
app.use('/api/v1/user-action', userActionRouter);
|
||||
app.use('/api/v1/organization', organizationRouter);
|
||||
|
||||
169
backend/src/integrations/apps.ts
Normal file
169
backend/src/integrations/apps.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import axios from 'axios';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
IIntegrationAuth
|
||||
} from '../models';
|
||||
import {
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY,
|
||||
INTEGRATION_HEROKU_API_URL,
|
||||
INTEGRATION_VERCEL_API_URL,
|
||||
INTEGRATION_NETLIFY_API_URL
|
||||
} from '../variables';
|
||||
|
||||
/**
|
||||
* Return list of names of apps for integration named [integration]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integration - name of integration
|
||||
* @param {String} obj.accessToken - access token for integration
|
||||
* @returns {Object[]} apps - names of integration apps
|
||||
* @returns {String} apps.name - name of integration app
|
||||
*/
|
||||
const getApps = async ({
|
||||
integrationAuth,
|
||||
accessToken
|
||||
}: {
|
||||
integrationAuth: IIntegrationAuth;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
|
||||
interface App {
|
||||
name: string;
|
||||
siteId?: string;
|
||||
}
|
||||
|
||||
let apps: App[]; // TODO: add type and define payloads for apps
|
||||
try {
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_HEROKU:
|
||||
apps = await getAppsHeroku({
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
apps = await getAppsVercel({
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
apps = await getAppsNetlify({
|
||||
integrationAuth,
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get integration apps');
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return list of names of apps for Heroku integration
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.accessToken - access token for Heroku API
|
||||
* @returns {Object[]} apps - names of Heroku apps
|
||||
* @returns {String} apps.name - name of Heroku app
|
||||
*/
|
||||
const getAppsHeroku = async ({
|
||||
accessToken
|
||||
}: {
|
||||
accessToken: string;
|
||||
}) => {
|
||||
let apps;
|
||||
try {
|
||||
const res = (await axios.get(`${INTEGRATION_HEROKU_API_URL}/apps`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.heroku+json; version=3',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})).data;
|
||||
|
||||
apps = res.map((a: any) => ({
|
||||
name: a.name
|
||||
}));
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get Heroku integration apps');
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return list of names of apps for Vercel integration
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.accessToken - access token for Vercel API
|
||||
* @returns {Object[]} apps - names of Vercel apps
|
||||
* @returns {String} apps.name - name of Vercel app
|
||||
*/
|
||||
const getAppsVercel = async ({
|
||||
accessToken
|
||||
}: {
|
||||
accessToken: string;
|
||||
}) => {
|
||||
let apps;
|
||||
try {
|
||||
const res = (await axios.get(`${INTEGRATION_VERCEL_API_URL}/v9/projects`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})).data;
|
||||
|
||||
apps = res.projects.map((a: any) => ({
|
||||
name: a.name
|
||||
}));
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get Vercel integration apps');
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return list of names of sites for Netlify integration
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.accessToken - access token for Netlify API
|
||||
* @returns {Object[]} apps - names of Netlify sites
|
||||
* @returns {String} apps.name - name of Netlify site
|
||||
*/
|
||||
const getAppsNetlify = async ({
|
||||
integrationAuth,
|
||||
accessToken
|
||||
}: {
|
||||
integrationAuth: IIntegrationAuth;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
let apps;
|
||||
try {
|
||||
const res = (await axios.get(`${INTEGRATION_NETLIFY_API_URL}/api/v1/sites`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
})).data;
|
||||
|
||||
apps = res.map((a: any) => ({
|
||||
name: a.name,
|
||||
siteId: a.site_id
|
||||
}));
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get Netlify integration apps');
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
export {
|
||||
getApps
|
||||
}
|
||||
241
backend/src/integrations/exchange.ts
Normal file
241
backend/src/integrations/exchange.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import axios from 'axios';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY,
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
INTEGRATION_VERCEL_TOKEN_URL,
|
||||
INTEGRATION_NETLIFY_TOKEN_URL,
|
||||
ACTION_PUSH_TO_HEROKU
|
||||
} from '../variables';
|
||||
import {
|
||||
SITE_URL,
|
||||
CLIENT_SECRET_HEROKU,
|
||||
CLIENT_ID_VERCEL,
|
||||
CLIENT_ID_NETLIFY,
|
||||
CLIENT_SECRET_VERCEL,
|
||||
CLIENT_SECRET_NETLIFY
|
||||
} from '../config';
|
||||
|
||||
interface ExchangeCodeHerokuResponse {
|
||||
token_type: string;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
user_id: string;
|
||||
session_nonce?: string;
|
||||
}
|
||||
|
||||
interface ExchangeCodeVercelResponse {
|
||||
token_type: string;
|
||||
access_token: string;
|
||||
installation_id: string;
|
||||
user_id: string;
|
||||
team_id?: string;
|
||||
}
|
||||
|
||||
interface ExchangeCodeNetlifyResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return [accessToken], [accessExpiresAt], and [refreshToken] for OAuth2
|
||||
* code-token exchange for integration named [integration]
|
||||
* @param {Object} obj1
|
||||
* @param {String} obj1.integration - name of integration
|
||||
* @param {String} obj1.code - code for code-token exchange
|
||||
* @returns {Object} obj
|
||||
* @returns {String} obj.accessToken - access token for integration
|
||||
* @returns {String} obj.refreshToken - refresh token for integration
|
||||
* @returns {Date} obj.accessExpiresAt - date of expiration for access token
|
||||
* @returns {String} obj.action - integration action for bot sequence
|
||||
*/
|
||||
const exchangeCode = async ({
|
||||
integration,
|
||||
code
|
||||
}: {
|
||||
integration: string;
|
||||
code: string;
|
||||
}) => {
|
||||
let obj = {} as any;
|
||||
|
||||
try {
|
||||
switch (integration) {
|
||||
case INTEGRATION_HEROKU:
|
||||
obj = await exchangeCodeHeroku({
|
||||
code
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
obj = await exchangeCodeVercel({
|
||||
code
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
obj = await exchangeCodeNetlify({
|
||||
code
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange');
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return [accessToken], [accessExpiresAt], and [refreshToken] for Heroku
|
||||
* OAuth2 code-token exchange
|
||||
* @param {Object} obj1
|
||||
* @param {Object} obj1.code - code for code-token exchange
|
||||
* @returns {Object} obj2
|
||||
* @returns {String} obj2.accessToken - access token for Heroku API
|
||||
* @returns {String} obj2.refreshToken - refresh token for Heroku API
|
||||
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
|
||||
*/
|
||||
const exchangeCodeHeroku = async ({
|
||||
code
|
||||
}: {
|
||||
code: string;
|
||||
}) => {
|
||||
let res: ExchangeCodeHerokuResponse;
|
||||
let accessExpiresAt = new Date();
|
||||
try {
|
||||
res = (await axios.post(
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
} as any)
|
||||
)).data;
|
||||
|
||||
accessExpiresAt.setSeconds(
|
||||
accessExpiresAt.getSeconds() + res.expires_in
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange with Heroku');
|
||||
}
|
||||
|
||||
return ({
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token,
|
||||
accessExpiresAt
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel
|
||||
* code-token exchange
|
||||
* @param {Object} obj1
|
||||
* @param {Object} obj1.code - code for code-token exchange
|
||||
* @returns {Object} obj2
|
||||
* @returns {String} obj2.accessToken - access token for Heroku API
|
||||
* @returns {String} obj2.refreshToken - refresh token for Heroku API
|
||||
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
|
||||
*/
|
||||
const exchangeCodeVercel = async ({
|
||||
code
|
||||
}: {
|
||||
code: string;
|
||||
}) => {
|
||||
let res: ExchangeCodeVercelResponse;
|
||||
try {
|
||||
res = (await axios.post(
|
||||
INTEGRATION_VERCEL_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
code: code,
|
||||
client_id: CLIENT_ID_VERCEL,
|
||||
client_secret: CLIENT_SECRET_VERCEL,
|
||||
redirect_uri: `${SITE_URL}/vercel`
|
||||
} as any)
|
||||
)).data;
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange with Vercel');
|
||||
}
|
||||
|
||||
return ({
|
||||
accessToken: res.access_token,
|
||||
refreshToken: null,
|
||||
accessExpiresAt: null,
|
||||
teamId: res.team_id
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return [accessToken], [accessExpiresAt], and [refreshToken] for Vercel
|
||||
* code-token exchange
|
||||
* @param {Object} obj1
|
||||
* @param {Object} obj1.code - code for code-token exchange
|
||||
* @returns {Object} obj2
|
||||
* @returns {String} obj2.accessToken - access token for Heroku API
|
||||
* @returns {String} obj2.refreshToken - refresh token for Heroku API
|
||||
* @returns {Date} obj2.accessExpiresAt - date of expiration for access token
|
||||
*/
|
||||
const exchangeCodeNetlify = async ({
|
||||
code
|
||||
}: {
|
||||
code: string;
|
||||
}) => {
|
||||
let res: ExchangeCodeNetlifyResponse;
|
||||
let accountId;
|
||||
try {
|
||||
res = (await axios.post(
|
||||
INTEGRATION_NETLIFY_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_id: CLIENT_ID_NETLIFY,
|
||||
client_secret: CLIENT_SECRET_NETLIFY,
|
||||
redirect_uri: `${SITE_URL}/netlify`
|
||||
} as any)
|
||||
)).data;
|
||||
|
||||
const res2 = await axios.get(
|
||||
'https://api.netlify.com/api/v1/sites',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${res.access_token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const res3 = (await axios.get(
|
||||
'https://api.netlify.com/api/v1/accounts',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${res.access_token}`
|
||||
}
|
||||
}
|
||||
)).data;
|
||||
|
||||
accountId = res3[0].id;
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed OAuth2 code-token exchange with Netlify');
|
||||
}
|
||||
|
||||
return ({
|
||||
accessToken: res.access_token,
|
||||
refreshToken: res.refresh_token,
|
||||
accountId
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
exchangeCode
|
||||
}
|
||||
13
backend/src/integrations/index.ts
Normal file
13
backend/src/integrations/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { exchangeCode } from './exchange';
|
||||
import { exchangeRefresh } from './refresh';
|
||||
import { getApps } from './apps';
|
||||
import { syncSecrets } from './sync';
|
||||
import { revokeAccess } from './revoke';
|
||||
|
||||
export {
|
||||
exchangeCode,
|
||||
exchangeRefresh,
|
||||
getApps,
|
||||
syncSecrets,
|
||||
revokeAccess
|
||||
}
|
||||
78
backend/src/integrations/refresh.ts
Normal file
78
backend/src/integrations/refresh.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import axios from 'axios';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { INTEGRATION_HEROKU } from '../variables';
|
||||
import {
|
||||
CLIENT_SECRET_HEROKU
|
||||
} from '../config';
|
||||
import {
|
||||
INTEGRATION_HEROKU_TOKEN_URL
|
||||
} from '../variables';
|
||||
|
||||
/**
|
||||
* Return new access token by exchanging refresh token [refreshToken] for integration
|
||||
* named [integration]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integration - name of integration
|
||||
* @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku
|
||||
*/
|
||||
const exchangeRefresh = async ({
|
||||
integration,
|
||||
refreshToken
|
||||
}: {
|
||||
integration: string;
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
let accessToken;
|
||||
try {
|
||||
switch (integration) {
|
||||
case INTEGRATION_HEROKU:
|
||||
accessToken = await exchangeRefreshHeroku({
|
||||
refreshToken
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get new OAuth2 access token');
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return new access token by exchanging refresh token [refreshToken] for the
|
||||
* Heroku integration
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.refreshToken - refresh token to use to get new access token for Heroku
|
||||
* @returns
|
||||
*/
|
||||
const exchangeRefreshHeroku = async ({
|
||||
refreshToken
|
||||
}: {
|
||||
refreshToken: string;
|
||||
}) => {
|
||||
let accessToken;
|
||||
try {
|
||||
const res = await axios.post(
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
} as any)
|
||||
);
|
||||
|
||||
accessToken = res.data.access_token;
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to get new OAuth2 access token for Heroku');
|
||||
}
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
export {
|
||||
exchangeRefresh
|
||||
}
|
||||
50
backend/src/integrations/revoke.ts
Normal file
50
backend/src/integrations/revoke.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import axios from 'axios';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
IIntegrationAuth,
|
||||
IntegrationAuth,
|
||||
Integration
|
||||
} from '../models';
|
||||
import {
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY
|
||||
} from '../variables';
|
||||
|
||||
const revokeAccess = async ({
|
||||
integrationAuth,
|
||||
accessToken
|
||||
}: {
|
||||
integrationAuth: IIntegrationAuth,
|
||||
accessToken: String
|
||||
}) => {
|
||||
try {
|
||||
// add any integration-specific revocation logic
|
||||
switch (integrationAuth.integration) {
|
||||
case INTEGRATION_HEROKU:
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
break;
|
||||
}
|
||||
|
||||
const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
|
||||
_id: integrationAuth._id
|
||||
});
|
||||
|
||||
if (deletedIntegrationAuth) {
|
||||
await Integration.deleteMany({
|
||||
integrationAuth: deletedIntegrationAuth._id
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to delete integration authorization');
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
revokeAccess
|
||||
}
|
||||
429
backend/src/integrations/sync.ts
Normal file
429
backend/src/integrations/sync.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import axios from 'axios';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
IIntegration, IIntegrationAuth
|
||||
} from '../models';
|
||||
import {
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY,
|
||||
INTEGRATION_HEROKU_API_URL,
|
||||
INTEGRATION_VERCEL_API_URL,
|
||||
INTEGRATION_NETLIFY_API_URL
|
||||
} from '../variables';
|
||||
|
||||
// TODO: need a helper function in the future to handle integration
|
||||
// envar priorities (i.e. prioritize secrets within integration or those on Infisical)
|
||||
|
||||
/**
|
||||
* Sync/push [secrets] to [app] in integration named [integration]
|
||||
* @param {Object} obj
|
||||
* @param {IIntegration} obj.integration - integration details
|
||||
* @param {IIntegrationAuth} obj.integrationAuth - integration auth details
|
||||
* @param {Object} obj.app - app in integration
|
||||
* @param {Object} obj.target - (optional) target (environment) in integration
|
||||
* @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values)
|
||||
* @param {String} obj.accessToken - access token for integration
|
||||
*/
|
||||
const syncSecrets = async ({
|
||||
integration,
|
||||
integrationAuth,
|
||||
secrets,
|
||||
accessToken,
|
||||
}: {
|
||||
integration: IIntegration;
|
||||
integrationAuth: IIntegrationAuth;
|
||||
secrets: any;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
try {
|
||||
switch (integration.integration) {
|
||||
case INTEGRATION_HEROKU:
|
||||
await syncSecretsHeroku({
|
||||
integration,
|
||||
secrets,
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_VERCEL:
|
||||
await syncSecretsVercel({
|
||||
integration,
|
||||
secrets,
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
case INTEGRATION_NETLIFY:
|
||||
await syncSecretsNetlify({
|
||||
integration,
|
||||
integrationAuth,
|
||||
secrets,
|
||||
accessToken
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to sync secrets to integration');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync/push [secrets] to Heroku [app]
|
||||
* @param {Object} obj
|
||||
* @param {IIntegration} obj.integration - integration details
|
||||
* @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values)
|
||||
*/
|
||||
const syncSecretsHeroku = async ({
|
||||
integration,
|
||||
secrets,
|
||||
accessToken
|
||||
}: {
|
||||
integration: IIntegration,
|
||||
secrets: any;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
try {
|
||||
const herokuSecrets = (await axios.get(
|
||||
`${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.heroku+json; version=3',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
)).data;
|
||||
|
||||
Object.keys(herokuSecrets).forEach(key => {
|
||||
if (!(key in secrets)) {
|
||||
secrets[key] = null;
|
||||
}
|
||||
});
|
||||
|
||||
await axios.patch(
|
||||
`${INTEGRATION_HEROKU_API_URL}/apps/${integration.app}/config-vars`,
|
||||
secrets,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/vnd.heroku+json; version=3',
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to sync secrets to Heroku');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync/push [secrets] to Heroku [app]
|
||||
* @param {Object} obj
|
||||
* @param {IIntegration} obj.integration - integration details
|
||||
* @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values)
|
||||
*/
|
||||
const syncSecretsVercel = async ({
|
||||
integration,
|
||||
secrets,
|
||||
accessToken
|
||||
}: {
|
||||
integration: IIntegration,
|
||||
secrets: any;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
|
||||
interface VercelSecret {
|
||||
id?: string;
|
||||
type: string;
|
||||
key: string;
|
||||
value: string;
|
||||
target: string[];
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all (decrypted) secrets back from Vercel in
|
||||
// decrypted format
|
||||
const params = new URLSearchParams({
|
||||
decrypt: "true"
|
||||
});
|
||||
|
||||
const res = (await Promise.all((await axios.get(
|
||||
`${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
))
|
||||
.data
|
||||
.envs
|
||||
.filter((secret: VercelSecret) => secret.target.includes(integration.target))
|
||||
.map(async (secret: VercelSecret) => (await axios.get(
|
||||
`${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
|
||||
}
|
||||
)).data)
|
||||
)).reduce((obj: any, secret: any) => ({
|
||||
...obj,
|
||||
[secret.key]: secret
|
||||
}), {});
|
||||
|
||||
let updateSecrets: VercelSecret[] = [];
|
||||
let deleteSecrets: VercelSecret[] = [];
|
||||
let newSecrets: VercelSecret[] = [];
|
||||
|
||||
// Identify secrets to create
|
||||
Object.keys(secrets).map((key) => {
|
||||
if (!(key in res)) {
|
||||
// case: secret has been created
|
||||
newSecrets.push({
|
||||
key: key,
|
||||
value: secrets[key],
|
||||
type: 'encrypted',
|
||||
target: [integration.target]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Identify secrets to update and delete
|
||||
Object.keys(res).map((key) => {
|
||||
if (key in secrets) {
|
||||
if (res[key].value !== secrets[key]) {
|
||||
// case: secret value has changed
|
||||
updateSecrets.push({
|
||||
id: res[key].id,
|
||||
key: key,
|
||||
value: secrets[key],
|
||||
type: 'encrypted',
|
||||
target: [integration.target]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// case: secret has been deleted
|
||||
deleteSecrets.push({
|
||||
id: res[key].id,
|
||||
key: key,
|
||||
value: res[key].value,
|
||||
type: 'encrypted',
|
||||
target: [integration.target],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sync/push new secrets
|
||||
if (newSecrets.length > 0) {
|
||||
await axios.post(
|
||||
`${INTEGRATION_VERCEL_API_URL}/v10/projects/${integration.app}/env`,
|
||||
newSecrets,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Sync/push updated secrets
|
||||
if (updateSecrets.length > 0) {
|
||||
updateSecrets.forEach(async (secret: VercelSecret) => {
|
||||
const {
|
||||
id,
|
||||
...updatedSecret
|
||||
} = secret;
|
||||
await axios.patch(
|
||||
`${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
|
||||
updatedSecret,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete secrets
|
||||
if (deleteSecrets.length > 0) {
|
||||
deleteSecrets.forEach(async (secret: VercelSecret) => {
|
||||
await axios.delete(
|
||||
`${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to sync secrets to Vercel');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync/push [secrets] to Netlify site [app]
|
||||
* @param {Object} obj
|
||||
* @param {IIntegration} obj.integration - integration details
|
||||
* @param {IIntegrationAuth} obj.integrationAuth - integration auth details
|
||||
* @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values)
|
||||
*/
|
||||
const syncSecretsNetlify = async ({
|
||||
integration,
|
||||
integrationAuth,
|
||||
secrets,
|
||||
accessToken
|
||||
}: {
|
||||
integration: IIntegration;
|
||||
integrationAuth: IIntegrationAuth;
|
||||
secrets: any;
|
||||
accessToken: string;
|
||||
}) => {
|
||||
try {
|
||||
const getParams = new URLSearchParams({
|
||||
context_name: integration.context,
|
||||
site_id: integration.siteId
|
||||
});
|
||||
|
||||
const res = (await axios.get(
|
||||
`${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`,
|
||||
{
|
||||
params: getParams,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
))
|
||||
.data
|
||||
.reduce((obj: any, secret: any) => ({
|
||||
...obj,
|
||||
[secret.key]: secret.values[0].value
|
||||
}), {});
|
||||
|
||||
interface UpdateNetlifySecret {
|
||||
key: string;
|
||||
context: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DeleteNetlifySecret {
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface NewNetlifySecretValue {
|
||||
value: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
interface NewNetlifySecret {
|
||||
key: string;
|
||||
values: NewNetlifySecretValue[];
|
||||
}
|
||||
|
||||
let updateSecrets: UpdateNetlifySecret[] = [];
|
||||
let deleteSecrets: DeleteNetlifySecret[] = [];
|
||||
let newSecrets: NewNetlifySecret[] = [];
|
||||
|
||||
// Identify secrets to create
|
||||
Object.keys(secrets).map((key) => {
|
||||
if (!(key in res)) {
|
||||
// case: secret has been created
|
||||
newSecrets.push({
|
||||
key: key,
|
||||
values: [{
|
||||
value: secrets[key], // include id?
|
||||
context: integration.context
|
||||
}]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Identify secrets to update and delete
|
||||
Object.keys(res).map((key) => {
|
||||
if (key in secrets) {
|
||||
if (res[key] !== secrets[key]) {
|
||||
// case: secret value has changed
|
||||
updateSecrets.push({
|
||||
key: key,
|
||||
context: integration.context,
|
||||
value: secrets[key]
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// case: secret has been deleted
|
||||
deleteSecrets.push({
|
||||
key
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const syncParams = new URLSearchParams({
|
||||
site_id: integration.siteId
|
||||
});
|
||||
|
||||
// Sync/push new secrets
|
||||
if (newSecrets.length > 0) {
|
||||
await axios.post(
|
||||
`${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env`,
|
||||
newSecrets,
|
||||
{
|
||||
params: syncParams,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Sync/push updated secrets
|
||||
if (updateSecrets.length > 0) {
|
||||
|
||||
updateSecrets.forEach(async (secret: UpdateNetlifySecret) => {
|
||||
await axios.patch(
|
||||
`${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`,
|
||||
{
|
||||
context: secret.context,
|
||||
value: secret.value
|
||||
},
|
||||
{
|
||||
params: syncParams,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete secrets
|
||||
if (deleteSecrets.length > 0) {
|
||||
deleteSecrets.forEach(async (secret: DeleteNetlifySecret) => {
|
||||
await axios.delete(
|
||||
`${INTEGRATION_NETLIFY_API_URL}/api/v1/accounts/${integrationAuth.accountId}/env/${secret.key}`,
|
||||
{
|
||||
params: syncParams,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
throw new Error('Failed to sync secrets to Heroku');
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
syncSecrets
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"heroku": {
|
||||
"name": "Heroku",
|
||||
"type": "oauth2",
|
||||
"clientId": "bc132901-935a-4590-b010-f1857efc380d",
|
||||
"docsLink": ""
|
||||
},
|
||||
"netlify": {
|
||||
"name": "Netlify",
|
||||
"type": "oauth2",
|
||||
"clientId": "",
|
||||
"docsLink": ""
|
||||
},
|
||||
"digitalocean": {
|
||||
"name": "Digital Ocean",
|
||||
"type": "oauth2",
|
||||
"clientId": "",
|
||||
"docsLink": ""
|
||||
},
|
||||
"gcp": {
|
||||
"name": "Google Cloud Platform",
|
||||
"type": "oauth2",
|
||||
"clientId": "",
|
||||
"docsLink": ""
|
||||
},
|
||||
"aws": {
|
||||
"name": "Amazon Web Services",
|
||||
"type": "oauth2",
|
||||
"clientId": "",
|
||||
"docsLink": ""
|
||||
},
|
||||
"azure": {
|
||||
"name": "Microsoft Azure",
|
||||
"type": "oauth2",
|
||||
"clientId": "",
|
||||
"docsLink": ""
|
||||
},
|
||||
"travisci": {
|
||||
"name": "Travis CI",
|
||||
"type": "oauth2",
|
||||
"clientId": "",
|
||||
"docsLink": ""
|
||||
},
|
||||
"circleci": {
|
||||
"name": "Circle CI",
|
||||
"type": "oauth2",
|
||||
"clientId": "",
|
||||
"docsLink": ""
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import requireAuth from './requireAuth';
|
||||
import requireBotAuth from './requireBotAuth';
|
||||
import requireSignupAuth from './requireSignupAuth';
|
||||
import requireWorkspaceAuth from './requireWorkspaceAuth';
|
||||
import requireOrganizationAuth from './requireOrganizationAuth';
|
||||
@@ -9,6 +10,7 @@ import validateRequest from './validateRequest';
|
||||
|
||||
export {
|
||||
requireAuth,
|
||||
requireBotAuth,
|
||||
requireSignupAuth,
|
||||
requireWorkspaceAuth,
|
||||
requireOrganizationAuth,
|
||||
|
||||
45
backend/src/middleware/requireBotAuth.ts
Normal file
45
backend/src/middleware/requireBotAuth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Bot } from '../models';
|
||||
import { validateMembership } from '../helpers/membership';
|
||||
|
||||
type req = 'params' | 'body' | 'query';
|
||||
|
||||
const requireBotAuth = ({
|
||||
acceptedRoles,
|
||||
acceptedStatuses,
|
||||
location = 'params'
|
||||
}: {
|
||||
acceptedRoles: string[];
|
||||
acceptedStatuses: string[];
|
||||
location?: req;
|
||||
}) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const bot = await Bot.findOne({ _id: req[location].botId });
|
||||
|
||||
if (!bot) {
|
||||
throw new Error('Failed to find bot');
|
||||
}
|
||||
|
||||
await validateMembership({
|
||||
userId: req.user._id.toString(),
|
||||
workspaceId: bot.workspace.toString(),
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
});
|
||||
|
||||
req.bot = bot;
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
Sentry.captureException(err);
|
||||
return res.status(401).send({
|
||||
error: 'Failed bot authorization'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default requireBotAuth;
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Integration, IntegrationAuth, Membership } from '../models';
|
||||
import { getOAuthAccessToken } from '../helpers/integrationAuth';
|
||||
import { Bot, Integration, IntegrationAuth, Membership } from '../models';
|
||||
import { IntegrationService } from '../services';
|
||||
import { validateMembership } from '../helpers/membership';
|
||||
|
||||
/**
|
||||
* Validate if user on request is a member of workspace with proper roles associated
|
||||
@@ -31,24 +32,14 @@ const requireIntegrationAuth = ({
|
||||
if (!integration) {
|
||||
throw new Error('Failed to find integration');
|
||||
}
|
||||
|
||||
const membership = await Membership.findOne({
|
||||
user: req.user._id,
|
||||
workspace: integration.workspace
|
||||
|
||||
await validateMembership({
|
||||
userId: req.user._id.toString(),
|
||||
workspaceId: integration.workspace.toString(),
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new Error('Failed to find integration workspace membership');
|
||||
}
|
||||
|
||||
if (!acceptedRoles.includes(membership.role)) {
|
||||
throw new Error('Failed to validate workspace membership role');
|
||||
}
|
||||
|
||||
if (!acceptedStatuses.includes(membership.status)) {
|
||||
throw new Error('Failed to validate workspace membership status');
|
||||
}
|
||||
|
||||
const integrationAuth = await IntegrationAuth.findOne({
|
||||
_id: integration.integrationAuth
|
||||
}).select(
|
||||
@@ -60,7 +51,9 @@ const requireIntegrationAuth = ({
|
||||
}
|
||||
|
||||
req.integration = integration;
|
||||
req.accessToken = await getOAuthAccessToken({ integrationAuth });
|
||||
req.accessToken = await IntegrationService.getIntegrationAuthAccess({
|
||||
integrationAuthId: integrationAuth._id.toString()
|
||||
});
|
||||
|
||||
return next();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { IntegrationAuth, Membership } from '../models';
|
||||
import { decryptSymmetric } from '../utils/crypto';
|
||||
import { getOAuthAccessToken } from '../helpers/integrationAuth';
|
||||
import { IntegrationAuth } from '../models';
|
||||
import { IntegrationService } from '../services';
|
||||
import { validateMembership } from '../helpers/membership';
|
||||
|
||||
/**
|
||||
* Validate if user on request is a member of workspace with proper roles associated
|
||||
@@ -10,18 +10,18 @@ import { getOAuthAccessToken } from '../helpers/integrationAuth';
|
||||
* @param {Object} obj
|
||||
* @param {String[]} obj.acceptedRoles - accepted workspace roles
|
||||
* @param {String[]} obj.acceptedStatuses - accepted workspace statuses
|
||||
* @param {Boolean} obj.attachRefresh - whether or not to decrypt and attach integration authorization refresh token onto request
|
||||
* @param {Boolean} obj.attachAccessToken - whether or not to decrypt and attach integration authorization access token onto request
|
||||
*/
|
||||
const requireIntegrationAuthorizationAuth = ({
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
acceptedStatuses,
|
||||
attachAccessToken = true
|
||||
}: {
|
||||
acceptedRoles: string[];
|
||||
acceptedStatuses: string[];
|
||||
attachAccessToken?: boolean;
|
||||
}) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// (authorization) integration authorization middleware
|
||||
|
||||
try {
|
||||
const { integrationAuthId } = req.params;
|
||||
|
||||
@@ -34,30 +34,21 @@ const requireIntegrationAuthorizationAuth = ({
|
||||
if (!integrationAuth) {
|
||||
throw new Error('Failed to find integration authorization');
|
||||
}
|
||||
|
||||
const membership = await Membership.findOne({
|
||||
user: req.user._id,
|
||||
workspace: integrationAuth.workspace
|
||||
|
||||
await validateMembership({
|
||||
userId: req.user._id.toString(),
|
||||
workspaceId: integrationAuth.workspace.toString(),
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new Error(
|
||||
'Failed to find integration authorization workspace membership'
|
||||
);
|
||||
}
|
||||
|
||||
if (!acceptedRoles.includes(membership.role)) {
|
||||
throw new Error('Failed to validate workspace membership role');
|
||||
}
|
||||
|
||||
if (!acceptedStatuses.includes(membership.status)) {
|
||||
throw new Error('Failed to validate workspace membership status');
|
||||
}
|
||||
|
||||
req.integrationAuth = integrationAuth;
|
||||
|
||||
// TODO: make compatible with other integration types since they won't necessarily have access tokens
|
||||
req.accessToken = await getOAuthAccessToken({ integrationAuth });
|
||||
if (attachAccessToken) {
|
||||
req.accessToken = await IntegrationService.getIntegrationAuthAccess({
|
||||
integrationAuthId: integrationAuth._id.toString()
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (err) {
|
||||
Sentry.setUser(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Membership, IWorkspace } from '../models';
|
||||
import { validateMembership } from '../helpers/membership';
|
||||
|
||||
type req = 'params' | 'body' | 'query';
|
||||
|
||||
@@ -25,24 +25,12 @@ const requireWorkspaceAuth = ({
|
||||
// workspace authorization middleware
|
||||
|
||||
try {
|
||||
// validate workspace membership
|
||||
|
||||
const membership = await Membership.findOne({
|
||||
user: req.user._id,
|
||||
workspace: req[location].workspaceId
|
||||
}).populate<{ workspace: IWorkspace }>('workspace');
|
||||
|
||||
if (!membership) {
|
||||
throw new Error('Failed to find workspace membership');
|
||||
}
|
||||
|
||||
if (!acceptedRoles.includes(membership.role)) {
|
||||
throw new Error('Failed to validate workspace membership role');
|
||||
}
|
||||
|
||||
if (!acceptedStatuses.includes(membership.status)) {
|
||||
throw new Error('Failed to validate workspace membership status');
|
||||
}
|
||||
const membership = await validateMembership({
|
||||
userId: req.user._id.toString(),
|
||||
workspaceId: req[location].workspaceId,
|
||||
acceptedRoles,
|
||||
acceptedStatuses
|
||||
});
|
||||
|
||||
req.membership = membership;
|
||||
|
||||
|
||||
57
backend/src/models/bot.ts
Normal file
57
backend/src/models/bot.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Schema, model, Types } from 'mongoose';
|
||||
|
||||
export interface IBot {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
workspace: Types.ObjectId;
|
||||
isActive: boolean;
|
||||
publicKey: string;
|
||||
encryptedPrivateKey: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
const botSchema = new Schema<IBot>(
|
||||
{
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
workspace: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'Workspace',
|
||||
required: true
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
publicKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
encryptedPrivateKey: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false
|
||||
},
|
||||
iv: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false
|
||||
},
|
||||
tag: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
const Bot = model<IBot>('Bot', botSchema);
|
||||
|
||||
export default Bot;
|
||||
45
backend/src/models/botKey.ts
Normal file
45
backend/src/models/botKey.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Schema, model, Types } from 'mongoose';
|
||||
|
||||
export interface IBotKey {
|
||||
_id: Types.ObjectId;
|
||||
encryptedKey: string;
|
||||
nonce: string;
|
||||
sender: Types.ObjectId;
|
||||
bot: Types.ObjectId;
|
||||
workspace: Types.ObjectId;
|
||||
}
|
||||
|
||||
const botKeySchema = new Schema<IBotKey>(
|
||||
{
|
||||
encryptedKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
nonce: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
sender: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'User',
|
||||
required: true
|
||||
},
|
||||
bot: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'Bot',
|
||||
required: true
|
||||
},
|
||||
workspace: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: 'Workspace',
|
||||
required: true
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
const BotKey = model<IBotKey>('BotKey', botKeySchema);
|
||||
|
||||
export default BotKey;
|
||||
@@ -1,4 +1,6 @@
|
||||
import BackupPrivateKey, { IBackupPrivateKey } from './backupPrivateKey';
|
||||
import Bot, { IBot } from './bot';
|
||||
import BotKey, { IBotKey } from './botKey';
|
||||
import IncidentContactOrg, { IIncidentContactOrg } from './incidentContactOrg';
|
||||
import Integration, { IIntegration } from './integration';
|
||||
import IntegrationAuth, { IIntegrationAuth } from './integrationAuth';
|
||||
@@ -16,6 +18,10 @@ import Workspace, { IWorkspace } from './workspace';
|
||||
export {
|
||||
BackupPrivateKey,
|
||||
IBackupPrivateKey,
|
||||
Bot,
|
||||
IBot,
|
||||
BotKey,
|
||||
IBotKey,
|
||||
IncidentContactOrg,
|
||||
IIncidentContactOrg,
|
||||
Integration,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ENV_STAGING,
|
||||
ENV_PROD,
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY
|
||||
} from '../variables';
|
||||
|
||||
@@ -14,7 +15,10 @@ export interface IIntegration {
|
||||
environment: 'dev' | 'test' | 'staging' | 'prod';
|
||||
isActive: boolean;
|
||||
app: string;
|
||||
integration: 'heroku' | 'netlify';
|
||||
target: string;
|
||||
context: string;
|
||||
siteId: string;
|
||||
integration: 'heroku' | 'vercel' | 'netlify';
|
||||
integrationAuth: Types.ObjectId;
|
||||
}
|
||||
|
||||
@@ -34,15 +38,29 @@ const integrationSchema = new Schema<IIntegration>(
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
app: {
|
||||
// name of app in provider
|
||||
app: { // name of app in provider
|
||||
type: String,
|
||||
default: null,
|
||||
required: true
|
||||
default: null
|
||||
},
|
||||
target: { // vercel-specific target (environment)
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
context: { // netlify-specific context (deploy)
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
siteId: { // netlify-specific site (app) id
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
integration: {
|
||||
type: String,
|
||||
enum: [INTEGRATION_HEROKU, INTEGRATION_NETLIFY],
|
||||
enum: [
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY
|
||||
],
|
||||
required: true
|
||||
},
|
||||
integrationAuth: {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Schema, model, Types } from 'mongoose';
|
||||
import { INTEGRATION_HEROKU, INTEGRATION_NETLIFY } from '../variables';
|
||||
import {
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY
|
||||
} from '../variables';
|
||||
|
||||
export interface IIntegrationAuth {
|
||||
_id: Types.ObjectId;
|
||||
workspace: Types.ObjectId;
|
||||
integration: 'heroku' | 'netlify';
|
||||
integration: 'heroku' | 'vercel' | 'netlify';
|
||||
teamId: string;
|
||||
accountId: string;
|
||||
refreshCiphertext?: string;
|
||||
refreshIV?: string;
|
||||
refreshTag?: string;
|
||||
@@ -22,9 +28,19 @@ const integrationAuthSchema = new Schema<IIntegrationAuth>(
|
||||
},
|
||||
integration: {
|
||||
type: String,
|
||||
enum: [INTEGRATION_HEROKU, INTEGRATION_NETLIFY],
|
||||
enum: [
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY
|
||||
],
|
||||
required: true
|
||||
},
|
||||
teamId: { // vercel-specific integration param
|
||||
type: String
|
||||
},
|
||||
accountId: { // netlify-specific integration param
|
||||
type: String
|
||||
},
|
||||
refreshCiphertext: {
|
||||
type: String,
|
||||
select: false
|
||||
|
||||
38
backend/src/routes/bot.ts
Normal file
38
backend/src/routes/bot.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
import { body, param } from 'express-validator';
|
||||
import {
|
||||
requireAuth,
|
||||
requireBotAuth,
|
||||
requireWorkspaceAuth,
|
||||
validateRequest
|
||||
} from '../middleware';
|
||||
import { botController } from '../controllers';
|
||||
import { ADMIN, MEMBER, COMPLETED, GRANTED } from '../variables';
|
||||
|
||||
router.get(
|
||||
'/:workspaceId',
|
||||
requireAuth,
|
||||
requireWorkspaceAuth({
|
||||
acceptedRoles: [ADMIN, MEMBER],
|
||||
acceptedStatuses: [COMPLETED, GRANTED]
|
||||
}),
|
||||
param('workspaceId').exists().trim().notEmpty(),
|
||||
validateRequest,
|
||||
botController.getBotByWorkspaceId
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:botId/active',
|
||||
requireAuth,
|
||||
requireBotAuth({
|
||||
acceptedRoles: [ADMIN, MEMBER],
|
||||
acceptedStatuses: [COMPLETED, GRANTED]
|
||||
}),
|
||||
body('isActive').isBoolean(),
|
||||
body('botKey'),
|
||||
validateRequest,
|
||||
botController.setBotActiveState
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,4 +1,5 @@
|
||||
import signup from './signup';
|
||||
import bot from './bot';
|
||||
import auth from './auth';
|
||||
import user from './user';
|
||||
import userAction from './userAction';
|
||||
@@ -18,6 +19,7 @@ import integrationAuth from './integrationAuth';
|
||||
export {
|
||||
signup,
|
||||
auth,
|
||||
bot,
|
||||
user,
|
||||
userAction,
|
||||
organization,
|
||||
|
||||
@@ -9,22 +9,6 @@ import { ADMIN, MEMBER, GRANTED } from '../variables';
|
||||
import { body, param } from 'express-validator';
|
||||
import { integrationController } from '../controllers';
|
||||
|
||||
router.get('/integrations', requireAuth, integrationController.getIntegrations);
|
||||
|
||||
router.post(
|
||||
'/:integrationId/sync',
|
||||
requireAuth,
|
||||
requireIntegrationAuth({
|
||||
acceptedRoles: [ADMIN, MEMBER],
|
||||
acceptedStatuses: [GRANTED]
|
||||
}),
|
||||
param('integrationId').exists().trim(),
|
||||
body('key').exists(),
|
||||
body('secrets').exists(),
|
||||
validateRequest,
|
||||
integrationController.syncIntegration
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:integrationId',
|
||||
requireAuth,
|
||||
@@ -32,10 +16,15 @@ router.patch(
|
||||
acceptedRoles: [ADMIN, MEMBER],
|
||||
acceptedStatuses: [GRANTED]
|
||||
}),
|
||||
param('integrationId'),
|
||||
body('update'),
|
||||
param('integrationId').exists().trim(),
|
||||
body('app').exists().trim(),
|
||||
body('environment').exists().trim(),
|
||||
body('isActive').exists().isBoolean(),
|
||||
body('target').exists(),
|
||||
body('context').exists(),
|
||||
body('siteId').exists(),
|
||||
validateRequest,
|
||||
integrationController.modifyIntegration
|
||||
integrationController.updateIntegration
|
||||
);
|
||||
|
||||
router.delete(
|
||||
@@ -45,7 +34,7 @@ router.delete(
|
||||
acceptedRoles: [ADMIN, MEMBER],
|
||||
acceptedStatuses: [GRANTED]
|
||||
}),
|
||||
param('integrationId'),
|
||||
param('integrationId').exists().trim(),
|
||||
validateRequest,
|
||||
integrationController.deleteIntegration
|
||||
);
|
||||
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
import { ADMIN, MEMBER, GRANTED } from '../variables';
|
||||
import { integrationAuthController } from '../controllers';
|
||||
|
||||
router.get(
|
||||
'/integration-options',
|
||||
requireAuth,
|
||||
integrationAuthController.getIntegrationOptions
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/oauth-token',
|
||||
requireAuth,
|
||||
@@ -22,7 +28,7 @@ router.post(
|
||||
body('code').exists().trim().notEmpty(),
|
||||
body('integration').exists().trim().notEmpty(),
|
||||
validateRequest,
|
||||
integrationAuthController.integrationAuthOauthExchange
|
||||
integrationAuthController.oAuthExchange
|
||||
);
|
||||
|
||||
router.get(
|
||||
@@ -42,7 +48,8 @@ router.delete(
|
||||
requireAuth,
|
||||
requireIntegrationAuthorizationAuth({
|
||||
acceptedRoles: [ADMIN, MEMBER],
|
||||
acceptedStatuses: [GRANTED]
|
||||
acceptedStatuses: [GRANTED],
|
||||
attachAccessToken: false
|
||||
}),
|
||||
param('integrationAuthId'),
|
||||
validateRequest,
|
||||
|
||||
82
backend/src/services/BotService.ts
Normal file
82
backend/src/services/BotService.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
getSecretsHelper,
|
||||
encryptSymmetricHelper,
|
||||
decryptSymmetricHelper
|
||||
} from '../helpers/bot';
|
||||
|
||||
/**
|
||||
* Class to handle bot actions
|
||||
*/
|
||||
class BotService {
|
||||
|
||||
/**
|
||||
* Return decrypted secrets for workspace with id [workspaceId] and
|
||||
* environment [environmen] shared to bot.
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace of secrets
|
||||
* @param {String} obj.environment - environment for secrets
|
||||
* @returns {Object} secretObj - object where keys are secret keys and values are secret values
|
||||
*/
|
||||
static async getSecrets({
|
||||
workspaceId,
|
||||
environment
|
||||
}: {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
}) {
|
||||
return await getSecretsHelper({
|
||||
workspaceId,
|
||||
environment
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return symmetrically encrypted [plaintext] using the
|
||||
* bot's copy of the workspace key for workspace with id [workspaceId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @param {String} obj.plaintext - plaintext to encrypt
|
||||
*/
|
||||
static async encryptSymmetric({
|
||||
workspaceId,
|
||||
plaintext
|
||||
}: {
|
||||
workspaceId: string;
|
||||
plaintext: string;
|
||||
}) {
|
||||
return await encryptSymmetricHelper({
|
||||
workspaceId,
|
||||
plaintext
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return symmetrically decrypted [ciphertext] using the
|
||||
* bot's copy of the workspace key for workspace with id [workspaceId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @param {String} obj.ciphertext - ciphertext to decrypt
|
||||
* @param {String} obj.iv - iv
|
||||
* @param {String} obj.tag - tag
|
||||
*/
|
||||
static async decryptSymmetric({
|
||||
workspaceId,
|
||||
ciphertext,
|
||||
iv,
|
||||
tag
|
||||
}: {
|
||||
workspaceId: string;
|
||||
ciphertext: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
}) {
|
||||
return await decryptSymmetricHelper({
|
||||
workspaceId,
|
||||
ciphertext,
|
||||
iv,
|
||||
tag
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default BotService;
|
||||
30
backend/src/services/EventService.ts
Normal file
30
backend/src/services/EventService.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Bot, IBot } from '../models';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { handleEventHelper } from '../helpers/event';
|
||||
|
||||
interface Event {
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class to handle events.
|
||||
*/
|
||||
class EventService {
|
||||
/**
|
||||
* Handle event [event]
|
||||
* @param {Object} obj
|
||||
* @param {Event} obj.event - an event
|
||||
* @param {String} obj.event.name - name of event
|
||||
* @param {String} obj.event.workspaceId - id of workspace that event is part of
|
||||
* @param {Object} obj.event.payload - payload of event (depends on event)
|
||||
*/
|
||||
static async handleEvent({ event }: { event: Event }): Promise<void> {
|
||||
await handleEventHelper({
|
||||
event
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default EventService;
|
||||
145
backend/src/services/IntegrationService.ts
Normal file
145
backend/src/services/IntegrationService.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import * as Sentry from '@sentry/node';
|
||||
import {
|
||||
Integration
|
||||
} from '../models';
|
||||
import {
|
||||
handleOAuthExchangeHelper,
|
||||
syncIntegrationsHelper,
|
||||
getIntegrationAuthRefreshHelper,
|
||||
getIntegrationAuthAccessHelper,
|
||||
setIntegrationAuthRefreshHelper,
|
||||
setIntegrationAuthAccessHelper,
|
||||
} from '../helpers/integration';
|
||||
import { exchangeCode } from '../integrations';
|
||||
import {
|
||||
ENV_DEV,
|
||||
EVENT_PUSH_SECRETS
|
||||
} from '../variables';
|
||||
|
||||
// should sync stuff be here too? Probably.
|
||||
// TODO: move bot functions to IntegrationService.
|
||||
|
||||
/**
|
||||
* Class to handle integrations
|
||||
*/
|
||||
class IntegrationService {
|
||||
|
||||
/**
|
||||
* Perform OAuth2 code-token exchange for workspace with id [workspaceId] and integration
|
||||
* named [integration]
|
||||
* - Store integration access and refresh tokens returned from the OAuth2 code-token exchange
|
||||
* - Add placeholder inactive integration
|
||||
* - Create bot sequence for integration
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId - id of workspace
|
||||
* @param {String} obj.integration - name of integration
|
||||
* @param {String} obj.code - code
|
||||
*/
|
||||
static async handleOAuthExchange({
|
||||
workspaceId,
|
||||
integration,
|
||||
code
|
||||
}: {
|
||||
workspaceId: string;
|
||||
integration: string;
|
||||
code: string;
|
||||
}) {
|
||||
await handleOAuthExchangeHelper({
|
||||
workspaceId,
|
||||
integration,
|
||||
code
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync/push environment variables in workspace with id [workspaceId] to
|
||||
* all associated integrations
|
||||
* @param {Object} obj
|
||||
* @param {Object} obj.workspaceId - id of workspace
|
||||
*/
|
||||
static async syncIntegrations({
|
||||
workspaceId
|
||||
}: {
|
||||
workspaceId: string;
|
||||
}) {
|
||||
return await syncIntegrationsHelper({
|
||||
workspaceId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return decrypted refresh token for integration auth
|
||||
* with id [integrationAuthId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationAuthId - id of integration auth
|
||||
* @param {String} refreshToken - decrypted refresh token
|
||||
*/
|
||||
static async getIntegrationAuthRefresh({ integrationAuthId }: { integrationAuthId: string}) {
|
||||
return await getIntegrationAuthRefreshHelper({
|
||||
integrationAuthId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return decrypted access token for integration auth
|
||||
* with id [integrationAuthId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationAuthId - id of integration auth
|
||||
* @param {String} accessToken - decrypted access token
|
||||
*/
|
||||
static async getIntegrationAuthAccess({ integrationAuthId }: { integrationAuthId: string}) {
|
||||
return await getIntegrationAuthAccessHelper({
|
||||
integrationAuthId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt refresh token [refreshToken] using the bot's copy
|
||||
* of the workspace key for workspace belonging to integration auth
|
||||
* with id [integrationAuthId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationAuthId - id of integration auth
|
||||
* @param {String} obj.refreshToken - refresh token
|
||||
* @returns {IntegrationAuth} integrationAuth - updated integration auth
|
||||
*/
|
||||
static async setIntegrationAuthRefresh({
|
||||
integrationAuthId,
|
||||
refreshToken
|
||||
}: {
|
||||
integrationAuthId: string;
|
||||
refreshToken: string;
|
||||
}) {
|
||||
return await setIntegrationAuthRefreshHelper({
|
||||
integrationAuthId,
|
||||
refreshToken
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt access token [accessToken] using the bot's copy
|
||||
* of the workspace key for workspace belonging to integration auth
|
||||
* with id [integrationAuthId]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationAuthId - id of integration auth
|
||||
* @param {String} obj.accessToken - access token
|
||||
* @param {String} obj.accessExpiresAt - expiration date of access token
|
||||
* @returns {IntegrationAuth} - updated integration auth
|
||||
*/
|
||||
static async setIntegrationAuthAccess({
|
||||
integrationAuthId,
|
||||
accessToken,
|
||||
accessExpiresAt
|
||||
}: {
|
||||
integrationAuthId: string;
|
||||
accessToken: string;
|
||||
accessExpiresAt: Date;
|
||||
}) {
|
||||
return await setIntegrationAuthAccessHelper({
|
||||
integrationAuthId,
|
||||
accessToken,
|
||||
accessExpiresAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default IntegrationService;
|
||||
@@ -1,5 +1,11 @@
|
||||
import postHogClient from './PostHogClient';
|
||||
import BotService from './BotService';
|
||||
import EventService from './EventService';
|
||||
import IntegrationService from './IntegrationService';
|
||||
|
||||
export {
|
||||
postHogClient
|
||||
postHogClient,
|
||||
BotService,
|
||||
EventService,
|
||||
IntegrationService
|
||||
}
|
||||
1
backend/src/types/express/index.d.ts
vendored
1
backend/src/types/express/index.d.ts
vendored
@@ -11,6 +11,7 @@ declare global {
|
||||
membershipOrg: any;
|
||||
integration: any;
|
||||
integrationAuth: any;
|
||||
bot: any;
|
||||
serviceToken: any;
|
||||
accessToken: any;
|
||||
query?: any;
|
||||
|
||||
@@ -2,6 +2,21 @@ 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.
|
||||
@@ -81,7 +96,7 @@ const decryptAsymmetric = ({
|
||||
* Return symmetrically encrypted [plaintext] using [key].
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.plaintext - plaintext to encrypt
|
||||
* @param {String} obj.key - 16-byte hex key
|
||||
* @param {String} obj.key - hex key
|
||||
*/
|
||||
const encryptSymmetric = ({
|
||||
plaintext,
|
||||
@@ -114,7 +129,7 @@ const encryptSymmetric = ({
|
||||
* @param {String} obj.ciphertext - ciphertext to decrypt
|
||||
* @param {String} obj.iv - iv
|
||||
* @param {String} obj.tag - tag
|
||||
* @param {String} obj.key - 32-byte hex key
|
||||
* @param {String} obj.key - hex key
|
||||
*
|
||||
*/
|
||||
const decryptSymmetric = ({
|
||||
@@ -139,6 +154,7 @@ const decryptSymmetric = ({
|
||||
};
|
||||
|
||||
export {
|
||||
generateKeyPair,
|
||||
encryptAsymmetric,
|
||||
decryptAsymmetric,
|
||||
encryptSymmetric,
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// membership roles
|
||||
const OWNER = 'owner';
|
||||
const ADMIN = 'admin';
|
||||
const MEMBER = 'member';
|
||||
|
||||
// membership statuses
|
||||
const INVITED = 'invited';
|
||||
|
||||
// -- organization
|
||||
const ACCEPTED = 'accepted';
|
||||
|
||||
// -- workspace
|
||||
const COMPLETED = 'completed';
|
||||
const GRANTED = 'granted';
|
||||
|
||||
// subscriptions
|
||||
const PLAN_STARTER = 'starter';
|
||||
const PLAN_PRO = 'pro';
|
||||
|
||||
// secrets
|
||||
const SECRET_SHARED = 'shared';
|
||||
const SECRET_PERSONAL = 'personal';
|
||||
|
||||
// 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]);
|
||||
|
||||
// integrations
|
||||
const INTEGRATION_HEROKU = 'heroku';
|
||||
const INTEGRATION_NETLIFY = 'netlify';
|
||||
const INTEGRATION_SET = new Set([INTEGRATION_HEROKU, INTEGRATION_NETLIFY]);
|
||||
|
||||
// integration types
|
||||
const INTEGRATION_OAUTH2 = 'oauth2';
|
||||
|
||||
export {
|
||||
OWNER,
|
||||
ADMIN,
|
||||
MEMBER,
|
||||
INVITED,
|
||||
ACCEPTED,
|
||||
COMPLETED,
|
||||
GRANTED,
|
||||
PLAN_STARTER,
|
||||
PLAN_PRO,
|
||||
SECRET_SHARED,
|
||||
SECRET_PERSONAL,
|
||||
ENV_DEV,
|
||||
ENV_TESTING,
|
||||
ENV_STAGING,
|
||||
ENV_PROD,
|
||||
ENV_SET,
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_NETLIFY,
|
||||
INTEGRATION_SET,
|
||||
INTEGRATION_OAUTH2
|
||||
};
|
||||
5
backend/src/variables/action.ts
Normal file
5
backend/src/variables/action.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
const ACTION_PUSH_TO_HEROKU = 'pushToHeroku';
|
||||
|
||||
export {
|
||||
ACTION_PUSH_TO_HEROKU
|
||||
}
|
||||
14
backend/src/variables/environment.ts
Normal file
14
backend/src/variables/environment.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// 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
|
||||
}
|
||||
7
backend/src/variables/event.ts
Normal file
7
backend/src/variables/event.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const EVENT_PUSH_SECRETS = 'pushSecrets';
|
||||
const EVENT_PULL_SECRETS = 'pullSecrets';
|
||||
|
||||
export {
|
||||
EVENT_PUSH_SECRETS,
|
||||
EVENT_PULL_SECRETS
|
||||
}
|
||||
79
backend/src/variables/index.ts
Normal file
79
backend/src/variables/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
ENV_DEV,
|
||||
ENV_TESTING,
|
||||
ENV_STAGING,
|
||||
ENV_PROD,
|
||||
ENV_SET
|
||||
} from './environment';
|
||||
import {
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY,
|
||||
INTEGRATION_SET,
|
||||
INTEGRATION_OAUTH2,
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
INTEGRATION_VERCEL_TOKEN_URL,
|
||||
INTEGRATION_NETLIFY_TOKEN_URL,
|
||||
INTEGRATION_HEROKU_API_URL,
|
||||
INTEGRATION_VERCEL_API_URL,
|
||||
INTEGRATION_NETLIFY_API_URL,
|
||||
INTEGRATION_OPTIONS
|
||||
} from './integration';
|
||||
import {
|
||||
OWNER,
|
||||
ADMIN,
|
||||
MEMBER,
|
||||
INVITED,
|
||||
ACCEPTED,
|
||||
COMPLETED,
|
||||
GRANTED
|
||||
} from './organization';
|
||||
import {
|
||||
SECRET_SHARED,
|
||||
SECRET_PERSONAL
|
||||
} from './secret';
|
||||
import {
|
||||
PLAN_STARTER,
|
||||
PLAN_PRO
|
||||
} from './stripe';
|
||||
import {
|
||||
EVENT_PUSH_SECRETS,
|
||||
EVENT_PULL_SECRETS
|
||||
} from './event';
|
||||
import {
|
||||
ACTION_PUSH_TO_HEROKU
|
||||
} from './action';
|
||||
|
||||
export {
|
||||
OWNER,
|
||||
ADMIN,
|
||||
MEMBER,
|
||||
INVITED,
|
||||
ACCEPTED,
|
||||
COMPLETED,
|
||||
GRANTED,
|
||||
PLAN_STARTER,
|
||||
PLAN_PRO,
|
||||
SECRET_SHARED,
|
||||
SECRET_PERSONAL,
|
||||
ENV_DEV,
|
||||
ENV_TESTING,
|
||||
ENV_STAGING,
|
||||
ENV_PROD,
|
||||
ENV_SET,
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY,
|
||||
INTEGRATION_SET,
|
||||
INTEGRATION_OAUTH2,
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
INTEGRATION_VERCEL_TOKEN_URL,
|
||||
INTEGRATION_NETLIFY_TOKEN_URL,
|
||||
INTEGRATION_HEROKU_API_URL,
|
||||
INTEGRATION_VERCEL_API_URL,
|
||||
INTEGRATION_NETLIFY_API_URL,
|
||||
EVENT_PUSH_SECRETS,
|
||||
EVENT_PULL_SECRETS,
|
||||
ACTION_PUSH_TO_HEROKU,
|
||||
INTEGRATION_OPTIONS
|
||||
};
|
||||
117
backend/src/variables/integration.ts
Normal file
117
backend/src/variables/integration.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
CLIENT_ID_HEROKU,
|
||||
CLIENT_ID_NETLIFY
|
||||
} from '../config';
|
||||
|
||||
// integrations
|
||||
const INTEGRATION_HEROKU = 'heroku';
|
||||
const INTEGRATION_VERCEL = 'vercel';
|
||||
const INTEGRATION_NETLIFY = 'netlify';
|
||||
const INTEGRATION_SET = new Set([
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY
|
||||
]);
|
||||
|
||||
// integration types
|
||||
const INTEGRATION_OAUTH2 = 'oauth2';
|
||||
|
||||
// integration oauth endpoints
|
||||
const INTEGRATION_HEROKU_TOKEN_URL = 'https://id.heroku.com/oauth/token';
|
||||
const INTEGRATION_VERCEL_TOKEN_URL = 'https://api.vercel.com/v2/oauth/access_token';
|
||||
const INTEGRATION_NETLIFY_TOKEN_URL = 'https://api.netlify.com/oauth/token';
|
||||
|
||||
// integration apps endpoints
|
||||
const INTEGRATION_HEROKU_API_URL = 'https://api.heroku.com';
|
||||
const INTEGRATION_VERCEL_API_URL = 'https://api.vercel.com';
|
||||
const INTEGRATION_NETLIFY_API_URL = 'https://api.netlify.com';
|
||||
|
||||
const INTEGRATION_OPTIONS = [
|
||||
{
|
||||
name: 'Heroku',
|
||||
slug: 'heroku',
|
||||
image: 'Heroku',
|
||||
isAvailable: true,
|
||||
type: 'oauth2',
|
||||
clientId: CLIENT_ID_HEROKU,
|
||||
docsLink: ''
|
||||
},
|
||||
{
|
||||
name: 'Vercel',
|
||||
slug: 'vercel',
|
||||
image: 'Vercel',
|
||||
isAvailable: true,
|
||||
type: 'vercel',
|
||||
clientId: '',
|
||||
docsLink: ''
|
||||
},
|
||||
{
|
||||
name: 'Netlify',
|
||||
slug: 'netlify',
|
||||
image: 'Netlify',
|
||||
isAvailable: true,
|
||||
type: 'oauth2',
|
||||
clientId: CLIENT_ID_NETLIFY,
|
||||
docsLink: ''
|
||||
},
|
||||
{
|
||||
name: 'Google Cloud Platform',
|
||||
slug: 'gcp',
|
||||
image: 'Google Cloud Platform',
|
||||
isAvailable: false,
|
||||
type: '',
|
||||
clientId: '',
|
||||
docsLink: ''
|
||||
},
|
||||
{
|
||||
name: 'Amazon Web Services',
|
||||
slug: 'aws',
|
||||
image: 'Amazon Web Services',
|
||||
isAvailable: false,
|
||||
type: '',
|
||||
clientId: '',
|
||||
docsLink: ''
|
||||
},
|
||||
{
|
||||
name: 'Microsoft Azure',
|
||||
slug: 'azure',
|
||||
image: 'Microsoft Azure',
|
||||
isAvailable: false,
|
||||
type: '',
|
||||
clientId: '',
|
||||
docsLink: ''
|
||||
},
|
||||
{
|
||||
name: 'Travis CI',
|
||||
slug: 'travisci',
|
||||
image: 'Travis CI',
|
||||
isAvailable: false,
|
||||
type: '',
|
||||
clientId: '',
|
||||
docsLink: ''
|
||||
},
|
||||
{
|
||||
name: 'Circle CI',
|
||||
slug: 'circleci',
|
||||
image: 'Circle CI',
|
||||
isAvailable: false,
|
||||
type: '',
|
||||
clientId: '',
|
||||
docsLink: ''
|
||||
}
|
||||
]
|
||||
|
||||
export {
|
||||
INTEGRATION_HEROKU,
|
||||
INTEGRATION_VERCEL,
|
||||
INTEGRATION_NETLIFY,
|
||||
INTEGRATION_SET,
|
||||
INTEGRATION_OAUTH2,
|
||||
INTEGRATION_HEROKU_TOKEN_URL,
|
||||
INTEGRATION_VERCEL_TOKEN_URL,
|
||||
INTEGRATION_NETLIFY_TOKEN_URL,
|
||||
INTEGRATION_HEROKU_API_URL,
|
||||
INTEGRATION_VERCEL_API_URL,
|
||||
INTEGRATION_NETLIFY_API_URL,
|
||||
INTEGRATION_OPTIONS
|
||||
}
|
||||
24
backend/src/variables/organization.ts
Normal file
24
backend/src/variables/organization.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// membership roles
|
||||
const OWNER = 'owner';
|
||||
const ADMIN = 'admin';
|
||||
const MEMBER = 'member';
|
||||
|
||||
// membership statuses
|
||||
const INVITED = 'invited';
|
||||
|
||||
// -- organization
|
||||
const ACCEPTED = 'accepted';
|
||||
|
||||
// -- workspace
|
||||
const COMPLETED = 'completed';
|
||||
const GRANTED = 'granted';
|
||||
|
||||
export {
|
||||
OWNER,
|
||||
ADMIN,
|
||||
MEMBER,
|
||||
INVITED,
|
||||
ACCEPTED,
|
||||
COMPLETED,
|
||||
GRANTED
|
||||
}
|
||||
8
backend/src/variables/secret.ts
Normal file
8
backend/src/variables/secret.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// secrets
|
||||
const SECRET_SHARED = 'shared';
|
||||
const SECRET_PERSONAL = 'personal';
|
||||
|
||||
export {
|
||||
SECRET_SHARED,
|
||||
SECRET_PERSONAL
|
||||
}
|
||||
7
backend/src/variables/stripe.ts
Normal file
7
backend/src/variables/stripe.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const PLAN_STARTER = 'starter';
|
||||
const PLAN_PRO = 'pro';
|
||||
|
||||
export {
|
||||
PLAN_STARTER,
|
||||
PLAN_PRO
|
||||
}
|
||||
@@ -51,6 +51,7 @@ services:
|
||||
env_file: .env
|
||||
environment:
|
||||
- NEXT_PUBLIC_ENV=development
|
||||
- INFISICAL_TELEMETRY_ENABLED=${TELEMETRY_ENABLED}
|
||||
- NEXT_PUBLIC_STRIPE_PRODUCT_PRO=${STRIPE_PRODUCT_PRO}
|
||||
- NEXT_PUBLIC_STRIPE_PRODUCT_STARTER=${STRIPE_PRODUCT_STARTER}
|
||||
networks:
|
||||
|
||||
@@ -28,6 +28,9 @@ Configuring Infisical requires setting some environment variables. There is a fi
|
||||
| `SMTP_USERNAME` | ❗️ Credential to connect to host (e.g. `team@infisical.com`) | `None` |
|
||||
| `SMTP_PASSWORD` | ❗️ Credential to connect to host | `None` |
|
||||
| `TELEMETRY_ENABLED` | `true` or `false`. [More](../overview). | `true` |
|
||||
| `OAUTH_CLIENT_SECRET_HEROKU` | OAuth client secret for Heroku integration | `None` |
|
||||
| `OAUTH_TOKEN_URL_HEROKU` | OAuth token URL for Heroku integration | `None` |
|
||||
| `CLIENT_ID_VERCEL` | OAuth client id for Vercel integration | `None` |
|
||||
| `CLIENT_ID_NETLIFY` | OAuth client id for Netlify integration | `None` |
|
||||
| `CLIENT_SECRET_HEROKU` | OAuth client secret for Heroku integration | `None` |
|
||||
| `CLIENT_SECRET_VERCEL` | OAuth client secret for Vercel integration | `None` |
|
||||
| `CLIENT_SECRET_NETLIFY` | OAuth client secret for Netlify integration | `None` |
|
||||
| `SENTRY_DSN` | DSN for error-monitoring with Sentry | `None` |
|
||||
|
||||
91
frontend/components/basic/dialog/ActivateBotDialog.js
Normal file
91
frontend/components/basic/dialog/ActivateBotDialog.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey";
|
||||
import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus";
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} from "../../utilities/cryptography/crypto";
|
||||
import Button from "../buttons/Button";
|
||||
|
||||
const ActivateBotDialog = ({
|
||||
isOpen,
|
||||
closeModal,
|
||||
selectedIntegrationOption,
|
||||
handleBotActivate,
|
||||
handleIntegrationOption
|
||||
}) => {
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// 1. activate bot
|
||||
await handleBotActivate();
|
||||
|
||||
// 2. start integration
|
||||
await handleIntegrationOption({
|
||||
integrationOption: selectedIntegrationOption
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-400"
|
||||
>
|
||||
Grant Infisical access to your secrets
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 mb-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 max-w-max">
|
||||
<Button
|
||||
onButtonPressed={submit}
|
||||
color="mineshaft"
|
||||
text="Grant access"
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivateBotDialog;
|
||||
100
frontend/components/basic/dialog/IntegrationAccessTokenDialog.js
Normal file
100
frontend/components/basic/dialog/IntegrationAccessTokenDialog.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Fragment } from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import getLatestFileKey from "../../../pages/api/workspace/getLatestFileKey";
|
||||
import setBotActiveStatus from "../../../pages/api/bot/setBotActiveStatus";
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} from "../../utilities/cryptography/crypto";
|
||||
import Button from "../buttons/Button";
|
||||
import InputField from "../InputField";
|
||||
|
||||
const IntegrationAccessTokenDialog = ({
|
||||
isOpen,
|
||||
closeModal,
|
||||
selectedIntegrationOption,
|
||||
handleBotActivate,
|
||||
handleIntegrationOption
|
||||
}) => {
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
// 1. activate bot
|
||||
await handleBotActivate();
|
||||
|
||||
// 2. start integration
|
||||
await handleIntegrationOption({
|
||||
integrationOption: selectedIntegrationOption
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Transition appear show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-md bg-bunker-800 border border-gray-700 p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-400"
|
||||
>
|
||||
Grant Infisical access to your secrets
|
||||
</Dialog.Title>
|
||||
<div className="mt-2 mb-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Most cloud integrations require Infisical to be able to decrypt your secrets so they can be forwarded over.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6 max-w-max">
|
||||
{/* <Button
|
||||
onButtonPressed={submit}
|
||||
color="mineshaft"
|
||||
text="Grant access"
|
||||
size="md"
|
||||
/> */}
|
||||
<InputField
|
||||
label="Access token"
|
||||
onChangeHandler={() => {}}
|
||||
type="varName"
|
||||
value={"Hello"}
|
||||
placeholder=""
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IntegrationAccessTokenDialog;
|
||||
121
frontend/components/integrations/CloudIntegration.tsx
Normal file
121
frontend/components/integrations/CloudIntegration.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCheck,
|
||||
faX,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import deleteIntegrationAuth from "../../pages/api/integrations/DeleteIntegrationAuth";
|
||||
|
||||
interface CloudIntegrationOption {
|
||||
isAvailable: Boolean;
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
docsLink: string;
|
||||
}
|
||||
|
||||
interface IntegrationAuth {
|
||||
id: string;
|
||||
integration: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cloudIntegrationOption: CloudIntegrationOption;
|
||||
setSelectedIntegrationOption: () => void;
|
||||
integrationOptionPress: () => void;
|
||||
integrationAuths: IntegrationAuth[];
|
||||
}
|
||||
|
||||
const CloudIntegration = ({
|
||||
cloudIntegrationOption,
|
||||
setSelectedIntegrationOption,
|
||||
integrationOptionPress,
|
||||
integrationAuths
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
return integrationAuths ? (
|
||||
<div
|
||||
className={`relative ${
|
||||
cloudIntegrationOption.isAvailable
|
||||
? "hover:bg-white/10 duration-200 cursor-pointer"
|
||||
: "opacity-50"
|
||||
} flex flex-row bg-white/5 h-32 rounded-md p-4 items-center`}
|
||||
onClick={() => {
|
||||
if (!cloudIntegrationOption.isAvailable) return;
|
||||
setSelectedIntegrationOption(cloudIntegrationOption);
|
||||
integrationOptionPress({
|
||||
integrationOption: cloudIntegrationOption
|
||||
});
|
||||
}}
|
||||
key={cloudIntegrationOption.name}
|
||||
>
|
||||
<Image
|
||||
src={`/images/integrations/${cloudIntegrationOption.name}.png`}
|
||||
height={70}
|
||||
width={70}
|
||||
alt="integration logo"
|
||||
/>
|
||||
{cloudIntegrationOption.name.split(" ").length > 2 ? (
|
||||
<div className="font-semibold text-gray-300 group-hover:text-gray-200 duration-200 text-3xl ml-4 max-w-xs">
|
||||
<div>{cloudIntegrationOption.name.split(" ")[0]}</div>
|
||||
<div className="text-base">
|
||||
{cloudIntegrationOption.name.split(" ")[1]}{" "}
|
||||
{cloudIntegrationOption.name.split(" ")[2]}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-semibold text-gray-300 group-hover:text-gray-200 duration-200 text-xl ml-4 max-w-xs">
|
||||
{cloudIntegrationOption.name}
|
||||
</div>
|
||||
)}
|
||||
{cloudIntegrationOption.isAvailable &&
|
||||
integrationAuths
|
||||
.map((authorization) => authorization.integration)
|
||||
.includes(cloudIntegrationOption.name.toLowerCase()) && (
|
||||
<div className="absolute group z-50 top-0 right-0 flex flex-row">
|
||||
<div
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
deleteIntegrationAuth({
|
||||
integrationAuthId: integrationAuths
|
||||
.filter(
|
||||
(authorization) =>
|
||||
authorization.integration ==
|
||||
cloudIntegrationOption.name.toLowerCase()
|
||||
)
|
||||
.map((authorization) => authorization._id)[0],
|
||||
});
|
||||
|
||||
router.reload();
|
||||
}}
|
||||
className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faX}
|
||||
className="text-xs mr-2 py-px"
|
||||
/>
|
||||
Revoke
|
||||
</div>
|
||||
<div className="w-max bg-primary py-0.5 px-2 rounded-bl-md rounded-tr-md text-xs flex flex-row items-center text-black opacity-90 group-hover:opacity-100 duration-200">
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-xs mr-2"
|
||||
/>
|
||||
Authorized
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!cloudIntegrationOption.isAvailable && (
|
||||
<div className="absolute group z-50 top-0 right-0 flex flex-row">
|
||||
<div className="w-max bg-yellow py-0.5 px-2 rounded-bl-md rounded-tr-md text-xs flex flex-row items-center text-black opacity-90">
|
||||
Coming Soon
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : <div></div>
|
||||
}
|
||||
|
||||
export default CloudIntegration;
|
||||
47
frontend/components/integrations/CloudIntegrationSection.tsx
Normal file
47
frontend/components/integrations/CloudIntegrationSection.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import CloudIntegration from "./CloudIntegration";
|
||||
|
||||
interface CloudIntegrationOption {
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
docsLink: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
cloudIntegrationOptions: CloudIntegrationOption[];
|
||||
setSelectedIntegrationOption: () => void;
|
||||
integrationOptionPress: () => void;
|
||||
integrationAuths: any;
|
||||
}
|
||||
|
||||
const CloudIntegrationSection = ({
|
||||
cloudIntegrationOptions,
|
||||
setSelectedIntegrationOption,
|
||||
integrationOptionPress,
|
||||
integrationAuths
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className={`flex flex-col justify-between items-start m-4 mt-7 text-xl max-w-5xl px-2`}>
|
||||
<h1 className="font-semibold text-3xl">Cloud Integrations</h1>
|
||||
<p className="text-base text-gray-400">
|
||||
Click on an integration to begin syncing secrets to it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-4 grid-rows-2 mx-6 max-w-5xl">
|
||||
{cloudIntegrationOptions.map((cloudIntegrationOption) => (
|
||||
<CloudIntegration
|
||||
cloudIntegrationOption={cloudIntegrationOption}
|
||||
setSelectedIntegrationOption={setSelectedIntegrationOption}
|
||||
integrationOptionPress={integrationOptionPress}
|
||||
integrationAuths={integrationAuths}
|
||||
key={`cloud-integration-${cloudIntegrationOption.slug}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CloudIntegrationSection;
|
||||
36
frontend/components/integrations/FrameworkIntegration.tsx
Normal file
36
frontend/components/integrations/FrameworkIntegration.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface Framework {
|
||||
name: string;
|
||||
slug: string;
|
||||
image: string;
|
||||
docsLink: string;
|
||||
}
|
||||
|
||||
const FrameworkIntegration = ({
|
||||
framework
|
||||
}: {
|
||||
framework: Framework;
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
href={framework.docsLink}
|
||||
rel="noopener"
|
||||
className={`relative flex flex-row items-center justify-center bg-bunker-500 hover:bg-gradient-to-tr duration-200 h-32 rounded-md p-0.5 items-center cursor-pointer`}
|
||||
>
|
||||
<div className={`hover:bg-white/10 duration-200 cursor-pointer font-semibold bg-bunker-500 flex flex-col items-center justify-center h-full w-full rounded-md text-gray-300 group-hover:text-gray-200 duration-200 ${framework?.name?.split(" ").length > 1 ? "text-sm px-1" : "text-xl px-2"} text-center w-full max-w-xs`}>
|
||||
{framework?.image && <Image
|
||||
src={`/images/integrations/${framework.image}.png`}
|
||||
height={framework?.name ? 60 : 90}
|
||||
width={framework?.name ? 60 : 90}
|
||||
alt="integration logo"
|
||||
></Image>}
|
||||
{framework?.name && framework?.image && <div className="h-2"></div>}
|
||||
{framework?.name && framework.name}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default FrameworkIntegration;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import FrameworkIntegration from "./FrameworkIntegration";
|
||||
|
||||
interface Framework {
|
||||
name: string;
|
||||
image: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
framework: Framework
|
||||
}
|
||||
|
||||
const FrameworkIntegrationSection = ({ frameworks }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col justify-between items-start mx-4 mt-12 mb-4 text-xl max-w-5xl px-2">
|
||||
<h1 className="font-semibold text-3xl">Framework Integrations</h1>
|
||||
<p className="text-base text-gray-400">
|
||||
Click on a framework to get the setup instructions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-7 grid-rows-2 mx-6 mt-4 max-w-5xl">
|
||||
{frameworks.map((framework) => (
|
||||
<FrameworkIntegration
|
||||
framework={framework}
|
||||
key={`framework-integration-${framework.slug}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FrameworkIntegrationSection;
|
||||
|
||||
209
frontend/components/integrations/Integration.tsx
Normal file
209
frontend/components/integrations/Integration.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faArrowRight,
|
||||
faRotate,
|
||||
faX,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
envMapping,
|
||||
reverseEnvMapping,
|
||||
reverseContextNetlifyMapping
|
||||
} from "../../public/data/frequentConstants";
|
||||
import updateIntegration from "../../pages/api/integrations/updateIntegration"
|
||||
import deleteIntegration from "../../pages/api/integrations/DeleteIntegration"
|
||||
import getIntegrationApps from "../../pages/api/integrations/GetIntegrationApps";
|
||||
import Button from "~/components/basic/buttons/Button";
|
||||
import ListBox from "~/components/basic/Listbox";
|
||||
|
||||
interface Integration {
|
||||
app?: string;
|
||||
environment: string;
|
||||
integration: string;
|
||||
integrationAuth: string;
|
||||
isActive: Boolean;
|
||||
}
|
||||
|
||||
const Integration = ({
|
||||
integration
|
||||
}: {
|
||||
integration: Integration;
|
||||
}) => {
|
||||
const [integrationEnvironment, setIntegrationEnvironment] = useState(
|
||||
reverseEnvMapping[integration.environment]
|
||||
);
|
||||
const [fileState, setFileState] = useState([]);
|
||||
const router = useRouter();
|
||||
const [apps, setApps] = useState([]); // integration app objects
|
||||
const [integrationApp, setIntegrationApp] = useState(null); // integration app name
|
||||
const [integrationTarget, setIntegrationTarget] = useState(null); // vercel-specific integration param
|
||||
const [integrationContext, setIntegrationContext] = useState(null); // netlify-specific integration param
|
||||
|
||||
useEffect(async () => {
|
||||
interface App {
|
||||
name: string;
|
||||
siteId?: string;
|
||||
}
|
||||
|
||||
const tempApps = await getIntegrationApps({
|
||||
integrationAuthId: integration.integrationAuth,
|
||||
});
|
||||
|
||||
setApps(tempApps);
|
||||
setIntegrationApp(
|
||||
integration.app ? integration.app : tempApps[0].name
|
||||
);
|
||||
|
||||
switch (integration.integration) {
|
||||
case "vercel":
|
||||
setIntegrationTarget("Development");
|
||||
break;
|
||||
case "netlify":
|
||||
setIntegrationContext("All");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderIntegrationSpecificParams = (integration) => {
|
||||
try {
|
||||
switch (integration.integration) {
|
||||
case "vercel":
|
||||
return (
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs font-semibold mb-2">
|
||||
ENVIRONMENT
|
||||
</div>
|
||||
<ListBox
|
||||
data={!integration.isActive && [
|
||||
"Production",
|
||||
"Preview",
|
||||
"Development"
|
||||
]}
|
||||
selected={"Production"}
|
||||
onChange={setIntegrationTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "netlify":
|
||||
return (
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs font-semibold mb-2">
|
||||
CONTEXT
|
||||
</div>
|
||||
<ListBox
|
||||
data={!integration.isActive && [
|
||||
"All",
|
||||
"Production",
|
||||
"Deploy previews",
|
||||
"Branch deploys",
|
||||
"Local development"
|
||||
]}
|
||||
selected={integrationContext}
|
||||
onChange={setIntegrationContext}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return <div></div>;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!integrationApp || apps.length === 0) return <div></div>
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl p-6 mx-6 mb-8 rounded-md bg-white/5 flex justify-between">
|
||||
<div className="flex">
|
||||
<div>
|
||||
<p className="text-gray-400 text-xs font-semibold mb-2">ENVIRONMENT</p>
|
||||
<ListBox data={!integration.isActive ? [
|
||||
"Development",
|
||||
"Staging",
|
||||
"Testing",
|
||||
"Production",
|
||||
] : null}
|
||||
selected={integrationEnvironment}
|
||||
onChange={setIntegrationEnvironment}
|
||||
isFull={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="mx-4 text-gray-400 mt-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-2">
|
||||
<p className="text-gray-400 text-xs font-semibold mb-2">
|
||||
INTEGRATION
|
||||
</p>
|
||||
<div className="py-2.5 bg-white/[.07] rounded-md pl-4 pr-10 text-sm font-semibold text-gray-300">
|
||||
{integration.integration.charAt(0).toUpperCase() +
|
||||
integration.integration.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-2">
|
||||
<div className="text-gray-400 text-xs font-semibold mb-2">
|
||||
APP
|
||||
</div>
|
||||
<ListBox
|
||||
data={!integration.isActive && apps.map((app) => app.name)}
|
||||
selected={integrationApp}
|
||||
onChange={setIntegrationApp}
|
||||
/>
|
||||
</div>
|
||||
{renderIntegrationSpecificParams(integration)}
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
{integration.isActive ? (
|
||||
<div className="max-w-5xl flex flex-row items-center bg-white/5 p-2 rounded-md px-4">
|
||||
<FontAwesomeIcon
|
||||
icon={faRotate}
|
||||
className="text-lg mr-2.5 text-primary animate-spin"
|
||||
/>
|
||||
<div className="text-gray-300 font-semibold">In Sync</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
text="Start Integration"
|
||||
onButtonPressed={async () => {
|
||||
const siteId = apps.find((app) => app.name === integrationApp).siteId ? apps.find((app) => app.name === integrationApp).siteId : null;
|
||||
const result = await updateIntegration({
|
||||
integrationId: integration._id,
|
||||
environment: envMapping[integrationEnvironment],
|
||||
app: integrationApp,
|
||||
isActive: true,
|
||||
target: integrationTarget ? integrationTarget.toLowerCase() : null,
|
||||
context: integrationContext ? reverseContextNetlifyMapping[integrationContext] : null,
|
||||
siteId
|
||||
});
|
||||
router.reload();
|
||||
}}
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
<div className="opacity-50 hover:opacity-100 duration-200 ml-2">
|
||||
<Button
|
||||
onButtonPressed={async () => {
|
||||
await deleteIntegration({
|
||||
integrationId: integration._id,
|
||||
});
|
||||
router.reload();
|
||||
}}
|
||||
color="red"
|
||||
size="icon-md"
|
||||
icon={faX}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Integration;
|
||||
34
frontend/components/integrations/IntegrationSection.tsx
Normal file
34
frontend/components/integrations/IntegrationSection.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import Integration from "./Integration";
|
||||
import guidGenerator from "~/utilities/randomId";
|
||||
|
||||
interface Integration {
|
||||
|
||||
}
|
||||
|
||||
interface Props {
|
||||
integrations: any
|
||||
}
|
||||
|
||||
const ProjectIntegrationSection = ({
|
||||
integrations
|
||||
}: Props) => {
|
||||
return integrations.length > 0 ? (
|
||||
<div className="mb-12">
|
||||
<div className="flex flex-col justify-between items-start mx-4 mb-4 mt-6 text-xl max-w-5xl px-2">
|
||||
<h1 className="font-semibold text-3xl">Current Integrations</h1>
|
||||
<p className="text-base text-gray-400">
|
||||
Manage your integrations of Infisical with third-party services.
|
||||
</p>
|
||||
</div>
|
||||
{integrations.map((integration => (
|
||||
<Integration
|
||||
key={guidGenerator()}
|
||||
integration={integration}
|
||||
/>
|
||||
)))}
|
||||
</div>
|
||||
) : <div></div>
|
||||
}
|
||||
|
||||
export default ProjectIntegrationSection;
|
||||
@@ -73,7 +73,7 @@ const attemptLogin = async (
|
||||
tag,
|
||||
privateKey
|
||||
});
|
||||
|
||||
|
||||
const userOrgs = await getOrganizations();
|
||||
const userOrgsData = userOrgs.map((org) => org._id);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ const POSTHOG_HOST =
|
||||
process.env.NEXT_PUBLIC_POSTHOG_HOST! || "https://app.posthog.com";
|
||||
const STRIPE_PRODUCT_PRO = process.env.NEXT_PUBLIC_STRIPE_PRODUCT_PRO!;
|
||||
const STRIPE_PRODUCT_STARTER = process.env.NEXT_PUBLIC_STRIPE_PRODUCT_STARTER!;
|
||||
const CLIENT_ID_HEROKU = process.env.NEXT_PUBLIC_CLIENT_ID_HEROKU!;
|
||||
const CLIENT_ID_NETLIFY = process.env.NEXT_PUBLIC_CLIENT_ID_NETLIFY!;
|
||||
|
||||
export {
|
||||
ENV,
|
||||
@@ -11,4 +13,6 @@ export {
|
||||
POSTHOG_HOST,
|
||||
STRIPE_PRODUCT_PRO,
|
||||
STRIPE_PRODUCT_STARTER,
|
||||
};
|
||||
CLIENT_ID_HEROKU,
|
||||
CLIENT_ID_NETLIFY
|
||||
};
|
||||
31
frontend/pages/api/bot/getBot.ts
Normal file
31
frontend/pages/api/bot/getBot.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import SecurityClient from "~/utilities/SecurityClient";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function fetches the bot for a project
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.workspaceId
|
||||
* @returns
|
||||
*/
|
||||
const getBot = async ({ workspaceId }: Props) => {
|
||||
return SecurityClient.fetchCall(
|
||||
"/api/v1/bot/" + workspaceId,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
}
|
||||
).then(async (res) => {
|
||||
if (res && res.status == 200) {
|
||||
return (await res.json()).bot;
|
||||
} else {
|
||||
console.log("Failed to get bot for project");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default getBot;
|
||||
46
frontend/pages/api/bot/setBotActiveStatus.ts
Normal file
46
frontend/pages/api/bot/setBotActiveStatus.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import SecurityClient from "~/utilities/SecurityClient";
|
||||
|
||||
interface BotKey {
|
||||
encryptedKey: string;
|
||||
nonce: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
botId: string;
|
||||
isActive: Boolean;
|
||||
botKey: BotKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function sets the active status of a bot and shares a copy of
|
||||
* the project key (encrypted under the bot's public key) with the
|
||||
* project's bot
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.botId
|
||||
* @param {String} obj.isActive
|
||||
* @param {Object} obj.botKey
|
||||
* @returns
|
||||
*/
|
||||
const setBotActiveStatus = async ({ botId, isActive, botKey }: Props) => {
|
||||
return SecurityClient.fetchCall(
|
||||
"/api/v1/bot/" + botId + "/active",
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
isActive,
|
||||
botKey
|
||||
})
|
||||
}
|
||||
).then(async (res) => {
|
||||
if (res && res.status == 200) {
|
||||
return await res.json();
|
||||
} else {
|
||||
console.log("Failed to get bot for project");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default setBotActiveStatus;
|
||||
21
frontend/pages/api/integrations/GetIntegrationOptions.ts
Normal file
21
frontend/pages/api/integrations/GetIntegrationOptions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import SecurityClient from '~/utilities/SecurityClient';
|
||||
|
||||
const getIntegrationOptions = () => {
|
||||
return SecurityClient.fetchCall(
|
||||
'/api/v1/integration-auth/integration-options',
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
).then(async (res) => {
|
||||
if (res && res.status == 200) {
|
||||
return (await res.json()).integrationOptions;
|
||||
} else {
|
||||
console.log('Failed to get (cloud) integration options');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default getIntegrationOptions;
|
||||
@@ -1,18 +0,0 @@
|
||||
import SecurityClient from '~/utilities/SecurityClient';
|
||||
|
||||
const getIntegrations = () => {
|
||||
return SecurityClient.fetchCall('/api/v1/integration/integrations', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(async (res) => {
|
||||
if (res && res.status == 200) {
|
||||
return (await res.json()).integrations;
|
||||
} else {
|
||||
console.log('Failed to get project integrations');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default getIntegrations;
|
||||
51
frontend/pages/api/integrations/updateIntegration.js
Normal file
51
frontend/pages/api/integrations/updateIntegration.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import SecurityClient from "~/utilities/SecurityClient";
|
||||
|
||||
/**
|
||||
* This route starts the integration after teh default one if gonna set up.
|
||||
* Update integration with id [integrationId] to sync envars from the project's
|
||||
* [environment] to the integration [app] with active state [isActive]
|
||||
* @param {Object} obj
|
||||
* @param {String} obj.integrationId - id of integration
|
||||
* @param {String} obj.app - name of app
|
||||
* @param {String} obj.environment - project environment to push secrets from
|
||||
* @param {Boolean} obj.isActive - active state
|
||||
* @param {String} obj.target - (optional) target (environment) for Vercel integration
|
||||
* @param {String} obj.context - (optional) context (environment) for Netlify integration
|
||||
* @param {String} obj.siteId - (optional) app (site_id) for Netlify integration
|
||||
* @returns
|
||||
*/
|
||||
const updateIntegration = ({
|
||||
integrationId,
|
||||
app,
|
||||
environment,
|
||||
isActive,
|
||||
target,
|
||||
context,
|
||||
siteId
|
||||
}) => {
|
||||
return SecurityClient.fetchCall(
|
||||
"/api/v1/integration/" + integrationId,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
app,
|
||||
environment,
|
||||
isActive,
|
||||
target,
|
||||
context,
|
||||
siteId
|
||||
}),
|
||||
}
|
||||
).then(async (res) => {
|
||||
if (res.status == 200) {
|
||||
return res;
|
||||
} else {
|
||||
console.log("Failed to start an integration");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default updateIntegration;
|
||||
@@ -413,29 +413,6 @@ export default function Dashboard() {
|
||||
setButtonReady(false);
|
||||
pushKeys({ obj, workspaceId: router.query.id, env });
|
||||
|
||||
/**
|
||||
* Check which integrations are active for this project and environment
|
||||
* If there are any, update environment variables for those integrations
|
||||
*/
|
||||
let integrations = await getWorkspaceIntegrations({
|
||||
workspaceId: router.query.id
|
||||
});
|
||||
integrations.map(async (integration) => {
|
||||
if (
|
||||
envMapping[env] == integration.environment &&
|
||||
integration.isActive == true
|
||||
) {
|
||||
let objIntegration = Object.assign(
|
||||
{},
|
||||
...data.map((row) => ({ [row.key]: row.value }))
|
||||
);
|
||||
await pushKeysIntegration({
|
||||
obj: objIntegration,
|
||||
integrationId: integration._id
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// If this user has never saved environment variables before, show them a prompt to read docs
|
||||
if (!hasUserEverPushed) {
|
||||
setCheckDocsPopUpVisible(true);
|
||||
|
||||
@@ -16,16 +16,17 @@ export default function Heroku() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(async () => {
|
||||
try {
|
||||
if (state == localStorage.getItem("latestCSRFToken")) {
|
||||
if (state === localStorage.getItem('latestCSRFToken')) {
|
||||
localStorage.removeItem('latestCSRFToken');
|
||||
await AuthorizeIntegration({
|
||||
workspaceId: localStorage.getItem("projectData.id"),
|
||||
workspaceId: localStorage.getItem('projectData.id'),
|
||||
code,
|
||||
integration: "heroku",
|
||||
});
|
||||
router.push("/integrations/" + localStorage.getItem("projectData.id"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error - Not logged in yet");
|
||||
console.error('Heroku integration error: ', error);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -1,199 +1,178 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
faArrowRight,
|
||||
faCheck,
|
||||
faRotate,
|
||||
faX
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
import Button from '~/components/basic/buttons/Button';
|
||||
import ListBox from '~/components/basic/Listbox';
|
||||
import NavHeader from '~/components/navigation/NavHeader';
|
||||
import getSecretsForProject from '~/components/utilities/secrets/getSecretsForProject';
|
||||
import pushKeysIntegration from '~/components/utilities/secrets/pushKeysIntegration';
|
||||
import guidGenerator from '~/utilities/randomId';
|
||||
|
||||
import {
|
||||
envMapping,
|
||||
frameworks,
|
||||
reverseEnvMapping
|
||||
} from '../../public/data/frequentConstants';
|
||||
import deleteIntegration from '../api/integrations/DeleteIntegration';
|
||||
import deleteIntegrationAuth from '../api/integrations/DeleteIntegrationAuth';
|
||||
import getIntegrationApps from '../api/integrations/GetIntegrationApps';
|
||||
import getIntegrations from '../api/integrations/GetIntegrations';
|
||||
import getWorkspaceAuthorizations from '../api/integrations/getWorkspaceAuthorizations';
|
||||
import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations';
|
||||
import startIntegration from '../api/integrations/StartIntegration';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const Integration = ({ projectIntegration }) => {
|
||||
const [integrationEnvironment, setIntegrationEnvironment] = useState(
|
||||
reverseEnvMapping[projectIntegration.environment]
|
||||
);
|
||||
const [fileState, setFileState] = useState([]);
|
||||
const [data, setData] = useState();
|
||||
const [isKeyAvailable, setIsKeyAvailable] = useState(true);
|
||||
const router = useRouter();
|
||||
const [apps, setApps] = useState([]);
|
||||
const [integrationApp, setIntegrationApp] = useState(
|
||||
projectIntegration.app ? projectIntegration.app : apps[0]
|
||||
);
|
||||
|
||||
useEffect(async () => {
|
||||
const tempHerokuApps = await getIntegrationApps({
|
||||
integrationAuthId: projectIntegration.integrationAuth
|
||||
});
|
||||
const tempHerokuAppNames = tempHerokuApps.map((app) => app.name);
|
||||
setApps(tempHerokuAppNames);
|
||||
setIntegrationApp(
|
||||
projectIntegration.app ? projectIntegration.app : tempHerokuAppNames[0]
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col max-w-5xl justify-center bg-white/5 p-6 rounded-md mx-6 mt-8">
|
||||
<div className="relative px-4 flex flex-row items-center justify-between mb-4">
|
||||
<div className="flex flex-row">
|
||||
<div>
|
||||
<div className="text-gray-400 self-start ml-1 mb-1 text-xs font-semibold tracking-wide">
|
||||
ENVIRONMENT
|
||||
</div>
|
||||
<ListBox
|
||||
data={
|
||||
!projectIntegration.isActive && [
|
||||
'Development',
|
||||
'Staging',
|
||||
'Production'
|
||||
]
|
||||
}
|
||||
selected={integrationEnvironment}
|
||||
onChange={setIntegrationEnvironment}
|
||||
/>
|
||||
</div>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="mx-4 text-gray-400 mt-8"
|
||||
/>
|
||||
<div className="mr-2">
|
||||
<div className="text-gray-400 self-start ml-1 mb-1 text-xs font-semibold tracking-wide">
|
||||
INTEGRATION
|
||||
</div>
|
||||
<div className="py-2.5 bg-white/[.07] rounded-md pl-4 pr-20 text-sm font-semibold text-gray-300">
|
||||
{projectIntegration.integration.charAt(0).toUpperCase() +
|
||||
projectIntegration.integration.slice(1)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 self-start ml-1 mb-1 text-xs font-semibold tracking-wide">
|
||||
HEROKU APP
|
||||
</div>
|
||||
<ListBox
|
||||
data={!projectIntegration.isActive && apps}
|
||||
selected={integrationApp}
|
||||
onChange={setIntegrationApp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row mt-6">
|
||||
{projectIntegration.isActive ? (
|
||||
<div className="max-w-5xl flex flex-row items-center bg-white/5 p-2 rounded-md px-4">
|
||||
<FontAwesomeIcon
|
||||
icon={faRotate}
|
||||
className="text-lg mr-2.5 text-primary animate-spin"
|
||||
/>
|
||||
<div className="text-gray-300 font-semibold">In Sync</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
text="Start Integration"
|
||||
onButtonPressed={async () => {
|
||||
const result = await startIntegration({
|
||||
integrationId: projectIntegration._id,
|
||||
environment: envMapping[integrationEnvironment],
|
||||
appName: integrationApp
|
||||
});
|
||||
if (result?.status == 200) {
|
||||
let currentSecrets = await getSecretsForProject({
|
||||
env: integrationEnvironment,
|
||||
setFileState,
|
||||
setIsKeyAvailable,
|
||||
setData,
|
||||
workspaceId: router.query.id
|
||||
});
|
||||
|
||||
let obj = Object.assign(
|
||||
{},
|
||||
...currentSecrets.map((row) => ({
|
||||
[row.key]: row.value
|
||||
}))
|
||||
);
|
||||
await pushKeysIntegration({
|
||||
obj,
|
||||
integrationId: projectIntegration._id
|
||||
});
|
||||
router.reload();
|
||||
}
|
||||
}}
|
||||
color="mineshaft"
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
<div className="opacity-50 hover:opacity-100 duration-200 ml-2">
|
||||
<Button
|
||||
onButtonPressed={async () => {
|
||||
await deleteIntegration({
|
||||
integrationId: projectIntegration._id
|
||||
});
|
||||
router.reload();
|
||||
}}
|
||||
color="red"
|
||||
size="icon-md"
|
||||
icon={faX}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Head from "next/head";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import NavHeader from "~/components/navigation/NavHeader";
|
||||
import Integration from "~/components/integrations/Integration";
|
||||
import FrameworkIntegrationSection from "~/components/integrations/FrameworkIntegrationSection";
|
||||
import CloudIntegrationSection from "~/components/integrations/CloudIntegrationSection";
|
||||
import IntegrationSection from "~/components/integrations/IntegrationSection";
|
||||
import frameworkIntegrationOptions from "../../public/json/frameworkIntegrations.json";
|
||||
import getWorkspaceAuthorizations from "../api/integrations/getWorkspaceAuthorizations";
|
||||
import getWorkspaceIntegrations from "../api/integrations/getWorkspaceIntegrations";
|
||||
import getIntegrationOptions from "../api/integrations/GetIntegrationOptions";
|
||||
import getBot from "../api/bot/getBot";
|
||||
import setBotActiveStatus from "../api/bot/setBotActiveStatus";
|
||||
import getLatestFileKey from "../api/workspace/getLatestFileKey";
|
||||
import ActivateBotDialog from "~/components/basic/dialog/ActivateBotDialog";
|
||||
import IntegrationAccessTokenDialog from "~/components/basic/dialog/IntegrationAccessTokenDialog";
|
||||
const {
|
||||
decryptAssymmetric,
|
||||
encryptAssymmetric
|
||||
} = require('../../components/utilities/cryptography/crypto');
|
||||
const crypto = require("crypto");
|
||||
|
||||
export default function Integrations() {
|
||||
const [integrations, setIntegrations] = useState();
|
||||
const [projectIntegrations, setProjectIntegrations] = useState();
|
||||
const [authorizations, setAuthorizations] = useState();
|
||||
const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]);
|
||||
const [integrationAuths, setIntegrationAuths] = useState([]);
|
||||
const [integrations, setIntegrations] = useState([]);
|
||||
const [bot, setBot] = useState(null);
|
||||
const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false);
|
||||
// const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true);
|
||||
const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null);
|
||||
|
||||
const router = useRouter();
|
||||
const [csrfToken, setCsrfToken] = useState('');
|
||||
|
||||
useEffect(async () => {
|
||||
const tempCSRFToken = crypto.randomBytes(16).toString('hex');
|
||||
setCsrfToken(tempCSRFToken);
|
||||
localStorage.setItem('latestCSRFToken', tempCSRFToken);
|
||||
|
||||
let projectAuthorizations = await getWorkspaceAuthorizations({
|
||||
workspaceId: router.query.id
|
||||
});
|
||||
setAuthorizations(projectAuthorizations);
|
||||
|
||||
const projectIntegrations = await getWorkspaceIntegrations({
|
||||
workspaceId: router.query.id
|
||||
});
|
||||
setProjectIntegrations(projectIntegrations);
|
||||
|
||||
try {
|
||||
const integrationsData = await getIntegrations();
|
||||
setIntegrations(integrationsData);
|
||||
} catch (error) {
|
||||
console.log('Error', error);
|
||||
// get cloud integration options
|
||||
setCloudIntegrationOptions(
|
||||
await getIntegrationOptions()
|
||||
);
|
||||
|
||||
// get project integration authorizations
|
||||
setIntegrationAuths(
|
||||
await getWorkspaceAuthorizations({
|
||||
workspaceId: router.query.id,
|
||||
})
|
||||
);
|
||||
|
||||
// get project integrations
|
||||
setIntegrations(
|
||||
await getWorkspaceIntegrations({
|
||||
workspaceId: router.query.id,
|
||||
})
|
||||
);
|
||||
|
||||
// get project bot
|
||||
setBot(
|
||||
await getBot({
|
||||
workspaceId: router.query.id
|
||||
}
|
||||
));
|
||||
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return integrations ? (
|
||||
/**
|
||||
* Activate bot for project by performing the following steps:
|
||||
* 1. Get the (encrypted) project key
|
||||
* 2. Decrypt project key with user's private key
|
||||
* 3. Encrypt project key with bot's public key
|
||||
* 4. Send encrypted project key to backend and set bot status to active
|
||||
*/
|
||||
const handleBotActivate = async () => {
|
||||
let botKey;
|
||||
try {
|
||||
|
||||
if (bot) {
|
||||
// case: there is a bot
|
||||
const key = await getLatestFileKey({ workspaceId: router.query.id });
|
||||
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY');
|
||||
|
||||
const WORKSPACE_KEY = decryptAssymmetric({
|
||||
ciphertext: key.latestKey.encryptedKey,
|
||||
nonce: key.latestKey.nonce,
|
||||
publicKey: key.latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const { ciphertext, nonce } = encryptAssymmetric({
|
||||
plaintext: WORKSPACE_KEY,
|
||||
publicKey: bot.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
botKey = {
|
||||
encryptedKey: ciphertext,
|
||||
nonce
|
||||
}
|
||||
|
||||
setBot((await setBotActiveStatus({
|
||||
botId: bot._id,
|
||||
isActive: bot.isActive ? false : true,
|
||||
botKey
|
||||
})).bot);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start integration for a given integration option [integrationOption]
|
||||
* @param {Object} obj
|
||||
* @param {Object} obj.integrationOption - an integration option
|
||||
* @param {String} obj.name
|
||||
* @param {String} obj.type
|
||||
* @param {String} obj.docsLink
|
||||
* @returns
|
||||
*/
|
||||
const handleIntegrationOption = async ({ integrationOption }) => {
|
||||
|
||||
try {
|
||||
// generate CSRF token for OAuth2 code-token exchange integrations
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
localStorage.setItem('latestCSRFToken', state);
|
||||
|
||||
switch (integrationOption.name) {
|
||||
case 'Heroku':
|
||||
window.location = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`;
|
||||
break;
|
||||
case 'Vercel':
|
||||
window.location = `https://vercel.com/integrations/infisical-dev/new?state=${state}`;
|
||||
break;
|
||||
case 'Netlify':
|
||||
window.location = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify`;
|
||||
break;
|
||||
// case 'Fly.io':
|
||||
// console.log('fly.io');
|
||||
// setIntegrationAccessTokenDialogOpen(true);
|
||||
// break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dialog to activate bot if bot is not active.
|
||||
* Otherwise, start integration [integrationOption]
|
||||
* @param {Object} obj
|
||||
* @param {Object} obj.integrationOption - an integration option
|
||||
* @param {String} obj.name
|
||||
* @param {String} obj.type
|
||||
* @param {String} obj.docsLink
|
||||
* @returns
|
||||
*/
|
||||
const integrationOptionPress = ({ integrationOption }) => {
|
||||
try {
|
||||
if (bot.isActive) {
|
||||
// case: bot is active -> proceed with integration
|
||||
handleIntegrationOption({ integrationOption });
|
||||
return;
|
||||
}
|
||||
|
||||
// case: bot is not active -> open modal to activate bot
|
||||
setIsActivateBotDialogOpen(true);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-bunker-800 max-h-screen flex flex-col justify-between text-white">
|
||||
<Head>
|
||||
<title>Dashboard</title>
|
||||
@@ -205,200 +184,41 @@ export default function Integrations() {
|
||||
content="Infisical a simple end-to-end encrypted platform that enables teams to sync and manage their .env files."
|
||||
/>
|
||||
</Head>
|
||||
<div className="flex flex-row">
|
||||
<div className="w-full max-h-96 pb-2 h-screen max-h-[calc(100vh-10px)] overflow-y-scroll no-scrollbar no-scrollbar::-webkit-scrollbar">
|
||||
<NavHeader pageName="Project Integrations" isProjectRelated={true} />
|
||||
<div className="flex flex-col justify-between items-start mx-4 mt-6 mb-4 text-xl max-w-5xl px-2">
|
||||
<div className="flex flex-row justify-start items-center text-3xl">
|
||||
<p className="font-semibold mr-4">Current Project Integrations</p>
|
||||
</div>
|
||||
<p className="mr-4 text-base text-gray-400">
|
||||
Manage your integrations of Infisical with third-party services.
|
||||
</p>
|
||||
</div>
|
||||
{projectIntegrations.length > 0 ? (
|
||||
projectIntegrations.map((projectIntegration) => (
|
||||
<Integration
|
||||
key={guidGenerator()}
|
||||
projectIntegration={projectIntegration}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col max-w-5xl justify-center bg-white/5 p-6 rounded-md mx-6 mt-8">
|
||||
<div className="relative px-4 flex flex-col text-gray-400 items-center justify-center">
|
||||
<div className="mb-1">
|
||||
You {"don't"} have any integrations set up yet. When you do,
|
||||
they will appear here.
|
||||
</div>
|
||||
<div className="">
|
||||
To start, click on any of the options below. It takes 5 clicks
|
||||
to set up.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col justify-between items-start mx-4 mt-12 mb-4 text-xl max-w-5xl px-2">
|
||||
<div className="flex flex-row justify-start items-center text-3xl">
|
||||
<p className="font-semibold mr-4">
|
||||
Platform & Cloud Integrations
|
||||
</p>
|
||||
</div>
|
||||
<p className="mr-4 text-base text-gray-400">
|
||||
Click on the itegration you want to connect. This will let your
|
||||
environment variables flow automatically into selected third-party
|
||||
services.
|
||||
</p>
|
||||
<p className="mr-4 text-xs text-gray-600 mt-1">
|
||||
Note: during an integration with Heroku, for security reasons, it
|
||||
is impossible to maintain end-to-end encryption. In theory, this
|
||||
lets Infisical decrypt yor environment variables. In practice, we
|
||||
can assure you that this will never be done, and it allows us to
|
||||
protect your secrets from bad actors online. The core Infisical
|
||||
service will always stay end-to-end encrypted. With any questions,
|
||||
reach out support@infisical.com.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-4 grid-rows-2 mx-6 mt-4 max-w-5xl">
|
||||
{Object.keys(integrations).map((integration) => (
|
||||
<div
|
||||
className={`relative ${
|
||||
['Heroku'].includes(integrations[integration].name)
|
||||
? ''
|
||||
: 'opacity-50'
|
||||
}`}
|
||||
key={integrations[integration].name}
|
||||
>
|
||||
<a
|
||||
href={`${
|
||||
['Heroku'].includes(integrations[integration].name)
|
||||
? `https://id.heroku.com/oauth/authorize?client_id=bc132901-935a-4590-b010-f1857efc380d&response_type=code&scope=write-protected&state=${csrfToken}`
|
||||
: '#'
|
||||
}`}
|
||||
rel="noopener"
|
||||
className={`relative flex flex-row bg-white/5 h-32 rounded-md p-4 items-center ${
|
||||
['Heroku'].includes(integrations[integration].name)
|
||||
? 'hover:bg-white/10 duration-200 cursor-pointer'
|
||||
: 'cursor-default grayscale'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={`/images/integrations/${integrations[integration].name}.png`}
|
||||
height={70}
|
||||
width={70}
|
||||
alt="integration logo"
|
||||
></Image>
|
||||
{integrations[integration].name.split(' ').length > 2 ? (
|
||||
<div className="font-semibold text-gray-300 group-hover:text-gray-200 duration-200 text-3xl ml-4 max-w-xs">
|
||||
<div>{integrations[integration].name.split(' ')[0]}</div>
|
||||
<div className="text-base">
|
||||
{integrations[integration].name.split(' ')[1]}{' '}
|
||||
{integrations[integration].name.split(' ')[2]}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-semibold text-gray-300 group-hover:text-gray-200 duration-200 text-xl ml-4 max-w-xs">
|
||||
{integrations[integration].name}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
{['Heroku'].includes(integrations[integration].name) &&
|
||||
authorizations
|
||||
.map((authorization) => authorization.integration)
|
||||
.includes(integrations[integration].name.toLowerCase()) && (
|
||||
<div className="absolute group z-50 top-0 right-0 flex flex-row">
|
||||
<div
|
||||
onClick={() => {
|
||||
deleteIntegrationAuth({
|
||||
integrationAuthId: authorizations
|
||||
.filter(
|
||||
(authorization) =>
|
||||
authorization.integration ==
|
||||
integrations[integration].name.toLowerCase()
|
||||
)
|
||||
.map((authorization) => authorization._id)[0]
|
||||
});
|
||||
router.reload();
|
||||
}}
|
||||
className="cursor-pointer w-max bg-red py-0.5 px-2 rounded-b-md text-xs flex flex-row items-center opacity-0 group-hover:opacity-100 duration-200"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faX}
|
||||
className="text-xs mr-2 py-px"
|
||||
/>
|
||||
Revoke
|
||||
</div>
|
||||
<div className="w-max bg-primary py-0.5 px-2 rounded-bl-md rounded-tr-md text-xs flex flex-row items-center text-black opacity-90 group-hover:opacity-100 duration-200">
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-xs mr-2"
|
||||
/>
|
||||
Authorized
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!['Heroku'].includes(integrations[integration].name) && (
|
||||
<div className="absolute group z-50 top-0 right-0 flex flex-row">
|
||||
<div className="w-max bg-yellow py-0.5 px-2 rounded-bl-md rounded-tr-md text-xs flex flex-row items-center text-black opacity-90">
|
||||
Coming Soon
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col justify-between items-start mx-4 mt-12 mb-4 text-xl max-w-5xl px-2">
|
||||
<div className="flex flex-row justify-start items-center text-3xl">
|
||||
<p className="font-semibold mr-4">Framework Integrations</p>
|
||||
</div>
|
||||
<p className="mr-4 text-base text-gray-400">
|
||||
Click on a framework to get the setup instructions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-4 grid-cols-7 grid-rows-2 mx-6 mt-4 max-w-5xl">
|
||||
{frameworks.map((framework) => (
|
||||
<div key={framework.name}>
|
||||
<a
|
||||
href={framework.link}
|
||||
rel="noopener"
|
||||
className={`relative flex flex-row items-center justify-center bg-bunker-500 hover:bg-gradient-to-tr hover:from-sky-400 hover:to-primary duration-200 h-32 rounded-md p-0.5 items-center cursor-pointer`}
|
||||
>
|
||||
<div
|
||||
className={`font-semibold bg-bunker-500 flex flex-col items-center justify-center h-full w-full rounded-md text-gray-300 group-hover:text-gray-200 duration-200 ${
|
||||
framework?.name?.split(' ').length > 1
|
||||
? 'text-sm px-1'
|
||||
: 'text-xl px-2'
|
||||
} text-center w-full max-w-xs`}
|
||||
>
|
||||
{framework?.image && (
|
||||
<Image
|
||||
src={`/images/integrations/${framework.image}.png`}
|
||||
height={framework?.name ? 60 : 90}
|
||||
width={framework?.name ? 60 : 90}
|
||||
alt="integration logo"
|
||||
></Image>
|
||||
)}
|
||||
{framework?.name && framework?.image && (
|
||||
<div className="h-2"></div>
|
||||
)}
|
||||
{framework?.name && framework.name}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full max-h-96 pb-2 h-screen max-h-[calc(100vh-10px)] overflow-y-scroll no-scrollbar no-scrollbar::-webkit-scrollbar">
|
||||
<NavHeader
|
||||
pageName="Project Integrations"
|
||||
isProjectRelated={true}
|
||||
/>
|
||||
<ActivateBotDialog
|
||||
isOpen={isActivateBotDialogOpen}
|
||||
closeModal={() => setIsActivateBotDialogOpen(false)}
|
||||
selectedIntegrationOption={selectedIntegrationOption}
|
||||
handleBotActivate={handleBotActivate}
|
||||
handleIntegrationOption={handleIntegrationOption}
|
||||
/>
|
||||
{/* <IntegrationAccessTokenDialog
|
||||
isOpen={isIntegrationAccessTokenDialogOpen}
|
||||
closeModal={() => setIntegrationAccessTokenDialogOpen(false)}
|
||||
selectedIntegrationOption={selectedIntegrationOption}
|
||||
handleBotActivate={handleBotActivate}
|
||||
handleIntegrationOption={handleIntegrationOption}
|
||||
/> */}
|
||||
<IntegrationSection integrations={integrations} />
|
||||
{cloudIntegrationOptions.length > 0 ? (
|
||||
<CloudIntegrationSection
|
||||
cloudIntegrationOptions={cloudIntegrationOptions}
|
||||
setSelectedIntegrationOption={setSelectedIntegrationOption}
|
||||
integrationOptionPress={integrationOptionPress}
|
||||
integrationAuths={integrationAuths}
|
||||
/>
|
||||
) : (
|
||||
<div></div>
|
||||
)}
|
||||
<FrameworkIntegrationSection
|
||||
frameworks={frameworkIntegrationOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative z-10 w-10/12 mr-auto h-full ml-2 bg-bunker-800 flex flex-col items-center justify-center">
|
||||
<div className="absolute top-0 bg-bunker h-14 border-b border-mineshaft-700 w-full"></div>
|
||||
<Image
|
||||
src="/images/loading/loading.gif"
|
||||
height={70}
|
||||
width={120}
|
||||
alt="loading animation"
|
||||
></Image>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
43
frontend/pages/netlify.js
Normal file
43
frontend/pages/netlify.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
const queryString = require("query-string");
|
||||
import AuthorizeIntegration from "./api/integrations/authorizeIntegration";
|
||||
|
||||
export default function Netlify() {
|
||||
const router = useRouter();
|
||||
const parsedUrl = queryString.parse(router.asPath.split("?")[1]);
|
||||
const code = parsedUrl.code;
|
||||
const state = parsedUrl.state;
|
||||
// modify comment here
|
||||
|
||||
/**
|
||||
* Here we forward to the default workspace if a user opens this url
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(async () => {
|
||||
if (state === localStorage.getItem('latestCSRFToken')) {
|
||||
localStorage.removeItem('latestCSRFToken');
|
||||
|
||||
await AuthorizeIntegration({
|
||||
workspaceId: localStorage.getItem('projectData.id'),
|
||||
code,
|
||||
integration: "netlify"
|
||||
});
|
||||
|
||||
router.push("/integrations/" + localStorage.getItem("projectData.id"));
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
} catch (err) {
|
||||
console.error('Netlify integration error: ', err);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
Netlify.requireAuth = true;
|
||||
42
frontend/pages/vercel.js
Normal file
42
frontend/pages/vercel.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
const queryString = require("query-string");
|
||||
import AuthorizeIntegration from "./api/integrations/authorizeIntegration";
|
||||
|
||||
export default function Vercel() {
|
||||
const router = useRouter();
|
||||
const parsedUrl = queryString.parse(router.asPath.split("?")[1]);
|
||||
const code = parsedUrl.code;
|
||||
const state = parsedUrl.state
|
||||
|
||||
/**
|
||||
* Here we forward to the default workspace if a user opens this url
|
||||
*/
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(async () => {
|
||||
if (state === localStorage.getItem('latestCSRFToken')) {
|
||||
localStorage.removeItem('latestCSRFToken');
|
||||
|
||||
await AuthorizeIntegration({
|
||||
workspaceId: localStorage.getItem('projectData.id'),
|
||||
code,
|
||||
integration: "vercel"
|
||||
});
|
||||
|
||||
router.push("/integrations/" + localStorage.getItem("projectData.id"));
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
} catch (err) {
|
||||
console.error('Vercel integration error: ', err);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
Vercel.requireAuth = true;
|
||||
@@ -12,71 +12,20 @@ const reverseEnvMapping = {
|
||||
test: "Testing",
|
||||
};
|
||||
|
||||
const frameworks = [{
|
||||
"name": "Docker",
|
||||
"image": "Docker",
|
||||
"link": "https://infisical.com/docs/integrations/platforms/docker"
|
||||
}, {
|
||||
"name": "Docker Compose",
|
||||
"image": "Docker Compose",
|
||||
"link": "https://infisical.com/docs/integrations/platforms/docker-compose"
|
||||
}, {
|
||||
"name": "React",
|
||||
"image": "React",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/react"
|
||||
}, {
|
||||
"name": "Vue",
|
||||
"image": "Vue",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/vue"
|
||||
}, {
|
||||
"image": "Express",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/express"
|
||||
},{
|
||||
"image": "Next.js",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/nextjs"
|
||||
}, {
|
||||
"name": "Django",
|
||||
"image": "Django",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/django"
|
||||
}, {
|
||||
"name": "NestJS",
|
||||
"image": "NestJS",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/nestjs"
|
||||
}, {
|
||||
"name": "Nuxt",
|
||||
"image": "Nuxt",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/nuxt"
|
||||
}, {
|
||||
"name": "Gatsby",
|
||||
"image": "Gatsby",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/gatsby"
|
||||
}, {
|
||||
"name": "Remix",
|
||||
"image": "Remix",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/remix"
|
||||
}, {
|
||||
"name": "Vite",
|
||||
"image": "Vite",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/vite"
|
||||
}, {
|
||||
"image": "Fiber",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/fiber"
|
||||
}, {
|
||||
"name": "Flask",
|
||||
"image": "Flask",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/flask"
|
||||
}, {
|
||||
"name": "Laravel",
|
||||
"image": "Laravel",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/laravel"
|
||||
}, {
|
||||
"image": "Rails",
|
||||
"link": "https://infisical.com/docs/integrations/frameworks/rails"
|
||||
}
|
||||
]
|
||||
const vercelMapping = {
|
||||
|
||||
}
|
||||
|
||||
const reverseContextNetlifyMapping = {
|
||||
"All": "all",
|
||||
"Local development": "dev",
|
||||
"Branch deploys": "branch-deploy",
|
||||
"Deploy Previews": "deploy-preview",
|
||||
"Production": "production"
|
||||
}
|
||||
|
||||
export {
|
||||
envMapping,
|
||||
frameworks,
|
||||
reverseEnvMapping
|
||||
reverseEnvMapping,
|
||||
reverseContextNetlifyMapping
|
||||
};
|
||||
|
||||
BIN
frontend/public/images/integrations/Vercel.png
Normal file
BIN
frontend/public/images/integrations/Vercel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
98
frontend/public/json/frameworkIntegrations.json
Normal file
98
frontend/public/json/frameworkIntegrations.json
Normal file
@@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"name": "Docker",
|
||||
"slug": "docker",
|
||||
"image": "Docker",
|
||||
"docsLink": "https://infisical.com/docs/integrations/platforms/docker"
|
||||
},
|
||||
{
|
||||
"name": "Docker Compose",
|
||||
"slug": "docker-compose",
|
||||
"image": "Docker Compose",
|
||||
"docsLink": "https://infisical.com/docs/integrations/platforms/docker-compose"
|
||||
},
|
||||
{
|
||||
"name": "React",
|
||||
"slug": "react",
|
||||
"image": "React",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/react"
|
||||
},
|
||||
{
|
||||
"name": "Vue",
|
||||
"slug": "vue",
|
||||
"image": "Vue",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/vue"
|
||||
},
|
||||
{
|
||||
"name": "Express",
|
||||
"slug": "express",
|
||||
"image": "Express",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/express"
|
||||
},
|
||||
{
|
||||
"name": "Next.js",
|
||||
"slug": "nextjs",
|
||||
"image": "Next.js",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/nextjs"
|
||||
},
|
||||
{
|
||||
"name": "Django",
|
||||
"slug": "django",
|
||||
"image": "Django",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/django"
|
||||
},
|
||||
{
|
||||
"name": "NestJS",
|
||||
"slug": "nestjs",
|
||||
"image": "NestJS",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/nestjs"
|
||||
},
|
||||
{
|
||||
"name": "Nuxt",
|
||||
"slug": "nuxt",
|
||||
"image": "Nuxt",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/nuxt"
|
||||
},
|
||||
{
|
||||
"name": "Gatsby",
|
||||
"slug": "gatsby",
|
||||
"image": "Gatsby",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/gatsby"
|
||||
},
|
||||
{
|
||||
"name": "Remix",
|
||||
"slug": "remix",
|
||||
"image": "Remix",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/remix"
|
||||
},
|
||||
{
|
||||
"name": "Vite",
|
||||
"slug": "vite",
|
||||
"image": "Vite",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/vite"
|
||||
},
|
||||
{
|
||||
"name": "Fiber",
|
||||
"slug": "fiber",
|
||||
"image": "Fiber",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/fiber"
|
||||
},
|
||||
{
|
||||
"name": "Flask",
|
||||
"slug": "flask",
|
||||
"image": "Flask",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/flask"
|
||||
},
|
||||
{
|
||||
"name": "Laravel",
|
||||
"slug": "laravel",
|
||||
"image": "Laravel",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/laravel"
|
||||
},
|
||||
{
|
||||
"name": "Rails",
|
||||
"slug": "rails",
|
||||
"image": "Rails",
|
||||
"docsLink": "https://infisical.com/docs/integrations/frameworks/rails"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user