Merge pull request #245 from Infisical/more-integrations

Render & Fly.io integrations, reduce page reloads for integrations page.
This commit is contained in:
BlackMagiq
2023-01-22 00:20:09 +07:00
committed by GitHub
48 changed files with 1155 additions and 349 deletions

View File

@ -1,8 +1,11 @@
import { Request, Response } from 'express';
import { Types } from 'mongoose';
import * as Sentry from '@sentry/node';
import axios from 'axios';
import { readFileSync } from 'fs';
import { IntegrationAuth, Integration } from '../../models';
import {
Integration,
IntegrationAuth,
Bot
} from '../../models';
import { INTEGRATION_SET, INTEGRATION_OPTIONS } from '../../variables';
import { IntegrationService } from '../../services';
import { getApps, revokeAccess } from '../../integrations';
@ -44,7 +47,7 @@ export const oAuthExchange = async (
environment: environments[0].slug,
});
} catch (err) {
Sentry.setUser(null);
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get OAuth2 code-token exchange'
@ -56,6 +59,67 @@ export const oAuthExchange = async (
});
};
/**
* Save integration access token as part of integration [integration] for workspace with id [workspaceId]
* @param req
* @param res
*/
export const saveIntegrationAccessToken = async (
req: Request,
res: Response
) => {
// TODO: refactor
let integrationAuth;
try {
const {
workspaceId,
accessToken,
integration
}: {
workspaceId: string;
accessToken: string;
integration: string;
} = req.body;
integrationAuth = await IntegrationAuth.findOneAndUpdate({
workspace: new Types.ObjectId(workspaceId),
integration
}, {
workspace: new Types.ObjectId(workspaceId),
integration
}, {
new: true,
upsert: true
});
const bot = await Bot.findOne({
workspace: new Types.ObjectId(workspaceId),
isActive: true
});
if (!bot) throw new Error('Bot must be enabled to save integration access token');
// encrypt and save integration access token
integrationAuth = await IntegrationService.setIntegrationAuthAccess({
integrationAuthId: integrationAuth._id.toString(),
accessToken,
accessExpiresAt: undefined
});
if (!integrationAuth) throw new Error('Failed to save integration access token');
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to save access token for integration'
});
}
return res.status(200).send({
integrationAuth
});
}
/**
* Return list of applications allowed for integration with integration authorization id [integrationAuthId]
* @param req
@ -70,7 +134,7 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => {
accessToken: req.accessToken
});
} catch (err) {
Sentry.setUser(null);
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to get integration authorization applications'
@ -89,15 +153,14 @@ export const getIntegrationAuthApps = async (req: Request, res: Response) => {
* @returns
*/
export const deleteIntegrationAuth = async (req: Request, res: Response) => {
let integrationAuth;
try {
const { integrationAuthId } = req.params;
await revokeAccess({
integrationAuth = await revokeAccess({
integrationAuth: req.integrationAuth,
accessToken: req.accessToken
});
} catch (err) {
Sentry.setUser(null);
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to delete integration authorization'
@ -105,6 +168,6 @@ export const deleteIntegrationAuth = async (req: Request, res: Response) => {
}
return res.status(200).send({
message: 'Successfully deleted integration authorization'
integrationAuth
});
}

View File

@ -1,25 +1,43 @@
import { Request, Response } from 'express';
import { readFileSync } from 'fs';
import * as Sentry from '@sentry/node';
import { Integration, Bot, BotKey } from '../../models';
import {
Integration,
Workspace,
Bot,
BotKey
} from '../../models';
import { EventService } from '../../services';
import { eventPushSecrets } from '../../events';
interface Key {
encryptedKey: string;
nonce: string;
}
/**
* Create/initialize an (empty) integration for integration authorization
* @param req
* @param res
* @returns
*/
export const createIntegration = async (req: Request, res: Response) => {
let integration;
try {
// initialize new integration after saving integration access token
integration = await new Integration({
workspace: req.integrationAuth.workspace._id,
isActive: false,
app: null,
environment: req.integrationAuth.workspace?.environments[0].slug,
integration: req.integrationAuth.integration,
integrationAuth: req.integrationAuth._id
}).save();
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to create integration'
});
}
interface PushSecret {
ciphertextKey: string;
ivKey: string;
tagKey: string;
hashKey: string;
ciphertextValue: string;
ivValue: string;
tagValue: string;
hashValue: string;
type: 'shared' | 'personal';
return res.status(200).send({
integration
});
}
/**
@ -36,13 +54,12 @@ export const updateIntegration = async (req: Request, res: Response) => {
try {
const {
app,
environment,
isActive,
target, // vercel-specific integration param
context, // netlify-specific integration param
siteId, // netlify-specific integration param
owner // github-specific integration param
app,
appId,
targetEnvironment,
owner, // github-specific integration param
} = req.body;
integration = await Integration.findOneAndUpdate(
@ -53,9 +70,8 @@ export const updateIntegration = async (req: Request, res: Response) => {
environment,
isActive,
app,
target,
context,
siteId,
appId,
targetEnvironment,
owner
},
{
@ -92,36 +108,15 @@ export const updateIntegration = async (req: Request, res: Response) => {
* @returns
*/
export const deleteIntegration = async (req: Request, res: Response) => {
let deletedIntegration;
let integration;
try {
const { integrationId } = req.params;
deletedIntegration = await Integration.findOneAndDelete({
integration = 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
});
}
}
if (!integration) throw new Error('Failed to find integration');
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
@ -129,8 +124,8 @@ export const deleteIntegration = async (req: Request, res: Response) => {
message: 'Failed to delete integration'
});
}
return res.status(200).send({
deletedIntegration
integration
});
};

View File

@ -127,7 +127,6 @@ const syncIntegrationsHelper = async ({
}) => {
let integrations;
try {
integrations = await Integration.find({
workspace: workspaceId,
isActive: true,
@ -142,7 +141,7 @@ const syncIntegrationsHelper = async ({
workspaceId: integration.workspace.toString(),
environment: integration.environment
});
const integrationAuth = await IntegrationAuth.findById(integration.integrationAuth);
if (!integrationAuth) throw new Error('Failed to find integration auth');
@ -316,7 +315,7 @@ const setIntegrationAuthAccessHelper = async ({
}: {
integrationAuthId: string;
accessToken: string;
accessExpiresAt: Date;
accessExpiresAt: Date | undefined;
}) => {
let integrationAuth;
try {

View File

@ -7,9 +7,13 @@ import {
INTEGRATION_VERCEL,
INTEGRATION_NETLIFY,
INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL
INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL
} from '../variables';
/**
@ -29,10 +33,11 @@ const getApps = async ({
}) => {
interface App {
name: string;
siteId?: string;
appId?: string;
owner?: string;
}
let apps: App[]; // TODO: add type and define payloads for apps
let apps: App[];
try {
switch (integrationAuth.integration) {
case INTEGRATION_HEROKU:
@ -48,13 +53,21 @@ const getApps = async ({
break;
case INTEGRATION_NETLIFY:
apps = await getAppsNetlify({
integrationAuth,
accessToken
});
break;
case INTEGRATION_GITHUB:
apps = await getAppsGithub({
integrationAuth,
accessToken
});
break;
case INTEGRATION_RENDER:
apps = await getAppsRender({
accessToken
});
break;
case INTEGRATION_FLYIO:
apps = await getAppsFlyio({
accessToken
});
break;
@ -69,7 +82,7 @@ const getApps = async ({
};
/**
* Return list of names of apps for Heroku integration
* Return list of apps for Heroku integration
* @param {Object} obj
* @param {String} obj.accessToken - access token for Heroku API
* @returns {Object[]} apps - names of Heroku apps
@ -141,17 +154,15 @@ const getAppsVercel = async ({
};
/**
* Return list of names of sites for Netlify integration
* Return list 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;
@ -166,7 +177,7 @@ const getAppsNetlify = async ({
apps = res.map((a: any) => ({
name: a.name,
siteId: a.site_id
appId: a.site_id
}));
} catch (err) {
Sentry.setUser(null);
@ -178,17 +189,15 @@ const getAppsNetlify = async ({
};
/**
* Return list of names of repositories for Github integration
* Return list of repositories for Github 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 getAppsGithub = async ({
integrationAuth,
accessToken
}: {
integrationAuth: IIntegrationAuth;
accessToken: string;
}) => {
let apps;
@ -220,4 +229,94 @@ const getAppsGithub = async ({
return apps;
};
/**
* Return list of services for Render integration
* @param {Object} obj
* @param {String} obj.accessToken - access token for Render API
* @returns {Object[]} apps - names and ids of Render services
* @returns {String} apps.name - name of Render service
* @returns {String} apps.appId - id of Render service
*/
const getAppsRender = async ({
accessToken
}: {
accessToken: string;
}) => {
let apps: any;
try {
const res = (
await axios.get(`${INTEGRATION_RENDER_API_URL}/v1/services`, {
headers: {
Authorization: `Bearer ${accessToken}`
}
})
).data;
apps = res
.map((a: any) => ({
name: a.service.name,
appId: a.service.id
}));
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to get Render services');
}
return apps;
}
/**
* Return list of apps for Fly.io integration
* @param {Object} obj
* @param {String} obj.accessToken - access token for Fly.io API
* @returns {Object[]} apps - names and ids of Fly.io apps
* @returns {String} apps.name - name of Fly.io apps
*/
const getAppsFlyio = async ({
accessToken
}: {
accessToken: string;
}) => {
let apps;
try {
const query = `
query($role: String) {
apps(type: "container", first: 400, role: $role) {
nodes {
id
name
hostname
}
}
}
`;
const res = (await axios({
url: INTEGRATION_FLYIO_API_URL,
method: 'post',
headers: {
'Authorization': 'Bearer ' + accessToken
},
data: {
query,
variables: {
role: null
}
}
})).data.data.apps.nodes;
apps = res
.map((a: any) => ({
name: a.name
}));
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to get Fly.io apps');
}
return apps;
}
export { getApps };

View File

@ -1,6 +1,11 @@
import axios from 'axios';
import * as Sentry from '@sentry/node';
import { IIntegrationAuth, IntegrationAuth, Integration } from '../models';
import {
IIntegrationAuth,
IntegrationAuth,
Integration,
Bot,
BotKey
} from '../models';
import {
INTEGRATION_HEROKU,
INTEGRATION_VERCEL,
@ -15,6 +20,7 @@ const revokeAccess = async ({
integrationAuth: IIntegrationAuth;
accessToken: string;
}) => {
let deletedIntegrationAuth;
try {
// add any integration-specific revocation logic
switch (integrationAuth.integration) {
@ -28,7 +34,7 @@ const revokeAccess = async ({
break;
}
const deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
deletedIntegrationAuth = await IntegrationAuth.findOneAndDelete({
_id: integrationAuth._id
});
@ -42,6 +48,8 @@ const revokeAccess = async ({
Sentry.captureException(err);
throw new Error('Failed to delete integration authorization');
}
return deletedIntegrationAuth;
};
export { revokeAccess };

View File

@ -1,4 +1,4 @@
import axios, { AxiosError } from 'axios';
import axios from 'axios';
import * as Sentry from '@sentry/node';
import { Octokit } from '@octokit/rest';
// import * as sodium from 'libsodium-wrappers';
@ -10,9 +10,13 @@ import {
INTEGRATION_VERCEL,
INTEGRATION_NETLIFY,
INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL
INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL
} from '../variables';
import { access, appendFile } from 'fs';
@ -21,8 +25,6 @@ import { access, appendFile } from 'fs';
* @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
*/
@ -69,6 +71,20 @@ const syncSecrets = async ({
accessToken
});
break;
case INTEGRATION_RENDER:
await syncSecretsRender({
integration,
secrets,
accessToken
});
break;
case INTEGRATION_FLYIO:
await syncSecretsFlyio({
integration,
secrets,
accessToken
});
break;
}
} catch (err) {
Sentry.setUser(null);
@ -78,10 +94,11 @@ const syncSecrets = async ({
};
/**
* Sync/push [secrets] to Heroku [app]
* Sync/push [secrets] to Heroku app named [integration.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)
* @param {String} obj.accessToken - access token for Heroku integration
*/
const syncSecretsHeroku = async ({
integration,
@ -129,7 +146,7 @@ const syncSecretsHeroku = async ({
};
/**
* Sync/push [secrets] to Heroku [app]
* Sync/push [secrets] to Vercel project named [integration.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)
@ -174,7 +191,7 @@ const syncSecretsVercel = async ({
))
.data
.envs
.filter((secret: VercelSecret) => secret.target.includes(integration.target))
.filter((secret: VercelSecret) => secret.target.includes(integration.targetEnvironment))
.map(async (secret: VercelSecret) => (await axios.get(
`${INTEGRATION_VERCEL_API_URL}/v9/projects/${integration.app}/env/${secret.id}`,
{
@ -201,7 +218,7 @@ const syncSecretsVercel = async ({
key: key,
value: secrets[key],
type: 'encrypted',
target: [integration.target]
target: [integration.targetEnvironment]
});
}
});
@ -216,7 +233,7 @@ const syncSecretsVercel = async ({
key: key,
value: secrets[key],
type: 'encrypted',
target: [integration.target]
target: [integration.targetEnvironment]
});
}
} else {
@ -226,7 +243,7 @@ const syncSecretsVercel = async ({
key: key,
value: res[key].value,
type: 'encrypted',
target: [integration.target],
target: [integration.targetEnvironment],
});
}
});
@ -287,11 +304,12 @@ const syncSecretsVercel = async ({
}
/**
* Sync/push [secrets] to Netlify site [app]
* Sync/push [secrets] to Netlify site with id [integration.appId]
* @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)
* @param {Object} obj.accessToken - access token for Netlify integration
*/
const syncSecretsNetlify = async ({
integration,
@ -323,7 +341,7 @@ const syncSecretsNetlify = async ({
const getParams = new URLSearchParams({
context_name: 'all', // integration.context or all
site_id: integration.siteId
site_id: integration.appId
});
const res = (await axios.get(
@ -354,7 +372,7 @@ const syncSecretsNetlify = async ({
key,
values: [{
value: secrets[key],
context: integration.context
context: integration.targetEnvironment
}]
});
} else {
@ -365,15 +383,15 @@ const syncSecretsNetlify = async ({
[value.context]: value
}), {});
if (integration.context in contexts) {
if (integration.targetEnvironment in contexts) {
// case: Netlify secret value exists in integration context
if (secrets[key] !== contexts[integration.context].value) {
if (secrets[key] !== contexts[integration.targetEnvironment].value) {
// case: Infisical and Netlify secret values are different
// -> update Netlify secret context and value
updateSecrets.push({
key,
values: [{
context: integration.context,
context: integration.targetEnvironment,
value: secrets[key]
}]
});
@ -384,7 +402,7 @@ const syncSecretsNetlify = async ({
updateSecrets.push({
key,
values: [{
context: integration.context,
context: integration.targetEnvironment,
value: secrets[key]
}]
});
@ -402,7 +420,7 @@ const syncSecretsNetlify = async ({
const numberOfValues = res[key].values.length;
res[key].values.forEach((value: NetlifyValue) => {
if (value.context === integration.context) {
if (value.context === integration.targetEnvironment) {
if (numberOfValues <= 1) {
// case: Netlify secret value has less than 1 context -> delete secret
deleteSecrets.push(key);
@ -412,7 +430,7 @@ const syncSecretsNetlify = async ({
key,
values: [{
id: value.id,
context: integration.context,
context: integration.targetEnvironment,
value: value.value
}]
});
@ -423,7 +441,7 @@ const syncSecretsNetlify = async ({
});
const syncParams = new URLSearchParams({
site_id: integration.siteId
site_id: integration.appId
});
if (newSecrets.length > 0) {
@ -492,11 +510,12 @@ const syncSecretsNetlify = async ({
}
/**
* Sync/push [secrets] to GitHub [repo]
* Sync/push [secrets] to GitHub repo with name [integration.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)
* @param {String} obj.accessToken - access token for GitHub integration
*/
const syncSecretsGitHub = async ({
integration,
@ -605,4 +624,175 @@ const syncSecretsGitHub = async ({
}
};
/**
* Sync/push [secrets] to Render service with id [integration.appId]
* @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)
* @param {String} obj.accessToken - access token for Render integration
*/
const syncSecretsRender = async ({
integration,
secrets,
accessToken
}: {
integration: IIntegration;
secrets: any;
accessToken: string;
}) => {
try {
await axios.put(
`${INTEGRATION_RENDER_API_URL}/v1/services/${integration.appId}/env-vars`,
Object.keys(secrets).map((key) => ({
key,
value: secrets[key]
})),
{
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to sync secrets to Render');
}
}
/**
* Sync/push [secrets] to Fly.io 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)
* @param {String} obj.accessToken - access token for Render integration
*/
const syncSecretsFlyio = async ({
integration,
secrets,
accessToken
}: {
integration: IIntegration;
secrets: any;
accessToken: string;
}) => {
try {
// set secrets
const SetSecrets = `
mutation($input: SetSecretsInput!) {
setSecrets(input: $input) {
release {
id
version
reason
description
user {
id
email
name
}
evaluationId
createdAt
}
}
}
`;
await axios({
url: INTEGRATION_FLYIO_API_URL,
method: 'post',
headers: {
'Authorization': 'Bearer ' + accessToken
},
data: {
query: SetSecrets,
variables: {
input: {
appId: integration.app,
secrets: Object.entries(secrets).map(([key, value]) => ({ key, value }))
}
}
}
});
// get secrets
interface FlyioSecret {
name: string;
digest: string;
createdAt: string;
}
const GetSecrets = `query ($appName: String!) {
app(name: $appName) {
secrets {
name
digest
createdAt
}
}
}`;
const getSecretsRes = (await axios({
method: 'post',
url: INTEGRATION_FLYIO_API_URL,
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
data: {
query: GetSecrets,
variables: {
appName: integration.app
}
}
})).data.data.app.secrets;
const deleteSecretsKeys = getSecretsRes
.filter((secret: FlyioSecret) => !(secret.name in secrets))
.map((secret: FlyioSecret) => secret.name);
// unset (delete) secrets
const DeleteSecrets = `mutation($input: UnsetSecretsInput!) {
unsetSecrets(input: $input) {
release {
id
version
reason
description
user {
id
email
name
}
evaluationId
createdAt
}
}
}`;
await axios({
method: 'post',
url: INTEGRATION_FLYIO_API_URL,
headers: {
'Authorization': 'Bearer ' + accessToken,
'Content-Type': 'application/json'
},
data: {
query: DeleteSecrets,
variables: {
input: {
appId: integration.app,
keys: deleteSecretsKeys
}
}
}
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to sync secrets to Fly.io');
}
}
export { syncSecrets };

View File

@ -1,10 +1,12 @@
import * as Sentry from '@sentry/node';
import { Request, Response, NextFunction } from 'express';
import { IntegrationAuth } from '../models';
import { IntegrationAuth, IWorkspace } from '../models';
import { IntegrationService } from '../services';
import { validateMembership } from '../helpers/membership';
import { UnauthorizedRequestError } from '../utils/errors';
type req = 'params' | 'body' | 'query';
/**
* Validate if user on request is a member of workspace with proper roles associated
* with the integration authorization on request params.
@ -14,17 +16,21 @@ import { UnauthorizedRequestError } from '../utils/errors';
*/
const requireIntegrationAuthorizationAuth = ({
acceptedRoles,
attachAccessToken = true
attachAccessToken = true,
location = 'params'
}: {
acceptedRoles: string[];
attachAccessToken?: boolean;
location?: req;
}) => {
return async (req: Request, res: Response, next: NextFunction) => {
const { integrationAuthId } = req.params;
const { integrationAuthId } = req[location];
const integrationAuth = await IntegrationAuth.findOne({
_id: integrationAuthId
}).select(
})
.populate<{ workspace: IWorkspace }>('workspace')
.select(
'+refreshCiphertext +refreshIV +refreshTag +accessCiphertext +accessIV +accessTag +accessExpiresAt'
);
@ -34,7 +40,7 @@ const requireIntegrationAuthorizationAuth = ({
await validateMembership({
userId: req.user._id.toString(),
workspaceId: integrationAuth.workspace.toString(),
workspaceId: integrationAuth.workspace._id.toString(),
acceptedRoles
});

View File

@ -3,7 +3,9 @@ import {
INTEGRATION_HEROKU,
INTEGRATION_VERCEL,
INTEGRATION_NETLIFY,
INTEGRATION_GITHUB
INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO
} from '../variables';
export interface IIntegration {
@ -12,20 +14,19 @@ export interface IIntegration {
environment: string;
isActive: boolean;
app: string;
target: string;
context: string;
siteId: string;
owner: string;
integration: 'heroku' | 'vercel' | 'netlify' | 'github';
targetEnvironment: string;
appId: string;
integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio';
integrationAuth: Types.ObjectId;
}
const integrationSchema = new Schema<IIntegration>(
{
workspace: {
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
},
environment: {
type: String,
@ -40,18 +41,13 @@ const integrationSchema = new Schema<IIntegration>(
type: String,
default: null
},
target: {
// vercel-specific target (environment)
appId: { // (new)
// id of app in provider
type: String,
default: null
},
context: {
// netlify-specific context (deploy)
type: String,
default: null
},
siteId: {
// netlify-specific site (app) id
targetEnvironment: { // (new)
// target environment
type: String,
default: null
},
@ -66,7 +62,9 @@ const integrationSchema = new Schema<IIntegration>(
INTEGRATION_HEROKU,
INTEGRATION_VERCEL,
INTEGRATION_NETLIFY,
INTEGRATION_GITHUB
INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO
],
required: true
},

View File

@ -9,7 +9,7 @@ import {
export interface IIntegrationAuth {
_id: Types.ObjectId;
workspace: Types.ObjectId;
integration: 'heroku' | 'vercel' | 'netlify' | 'github';
integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'render' | 'flyio';
teamId: string;
accountId: string;
refreshCiphertext?: string;
@ -24,8 +24,9 @@ export interface IIntegrationAuth {
const integrationAuthSchema = new Schema<IIntegrationAuth>(
{
workspace: {
type: Schema.Types.ObjectId,
required: true
type: Schema.Types.ObjectId,
ref: 'Workspace',
required: true
},
integration: {
type: String,

View File

@ -3,12 +3,27 @@ const router = express.Router();
import {
requireAuth,
requireIntegrationAuth,
requireIntegrationAuthorizationAuth,
validateRequest
} from '../../middleware';
import { ADMIN, MEMBER } from '../../variables';
import { body, param } from 'express-validator';
import { integrationController } from '../../controllers/v1';
router.post( // new: add new integration
'/',
requireAuth({
acceptedAuthModes: ['jwt', 'apiKey']
}),
requireIntegrationAuthorizationAuth({
acceptedRoles: [ADMIN, MEMBER],
location: 'body'
}),
body('integrationAuthId').exists().trim(),
validateRequest,
integrationController.createIntegration
);
router.patch(
'/:integrationId',
requireAuth({
@ -18,12 +33,11 @@ router.patch(
acceptedRoles: [ADMIN, MEMBER]
}),
param('integrationId').exists().trim(),
body('isActive').exists().isBoolean(),
body('app').exists().trim(),
body('environment').exists().trim(),
body('isActive').exists().isBoolean(),
body('target').exists(),
body('context').exists(),
body('siteId').exists(),
body('appId').exists(),
body('targetEnvironment').exists(),
body('owner').exists(),
validateRequest,
integrationController.updateIntegration

View File

@ -34,6 +34,22 @@ router.post(
integrationAuthController.oAuthExchange
);
router.post(
'/access-token',
requireAuth({
acceptedAuthModes: ['jwt', 'apiKey']
}),
requireWorkspaceAuth({
acceptedRoles: [ADMIN, MEMBER],
location: 'body'
}),
body('workspaceId').exists().trim().notEmpty(),
body('accessToken').exists().trim().notEmpty(),
body('integration').exists().trim().notEmpty(),
validateRequest,
integrationAuthController.saveIntegrationAccessToken
);
router.get(
'/:integrationAuthId/apps',
requireAuth({

View File

@ -122,7 +122,7 @@ class IntegrationService {
* @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
* @param {Date} obj.accessExpiresAt - expiration date of access token
* @returns {IntegrationAuth} - updated integration auth
*/
static async setIntegrationAuthAccess({
@ -132,7 +132,7 @@ class IntegrationService {
}: {
integrationAuthId: string;
accessToken: string;
accessExpiresAt: Date;
accessExpiresAt: Date | undefined;
}) {
return await setIntegrationAuthAccessHelper({
integrationAuthId,

View File

@ -10,6 +10,8 @@ import {
INTEGRATION_VERCEL,
INTEGRATION_NETLIFY,
INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_SET,
INTEGRATION_OAUTH2,
INTEGRATION_HEROKU_TOKEN_URL,
@ -19,6 +21,8 @@ import {
INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL,
INTEGRATION_OPTIONS
} from './integration';
import {
@ -56,6 +60,8 @@ export {
INTEGRATION_VERCEL,
INTEGRATION_NETLIFY,
INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_SET,
INTEGRATION_OAUTH2,
INTEGRATION_HEROKU_TOKEN_URL,
@ -65,6 +71,8 @@ export {
INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL,
EVENT_PUSH_SECRETS,
EVENT_PULL_SECRETS,
ACTION_ADD_SECRETS,

View File

@ -10,11 +10,15 @@ const INTEGRATION_HEROKU = 'heroku';
const INTEGRATION_VERCEL = 'vercel';
const INTEGRATION_NETLIFY = 'netlify';
const INTEGRATION_GITHUB = 'github';
const INTEGRATION_RENDER = 'render';
const INTEGRATION_FLYIO = 'flyio';
const INTEGRATION_SET = new Set([
INTEGRATION_HEROKU,
INTEGRATION_VERCEL,
INTEGRATION_NETLIFY,
INTEGRATION_GITHUB
INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO
]);
// integration types
@ -32,23 +36,25 @@ const INTEGRATION_GITHUB_TOKEN_URL =
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_RENDER_API_URL = 'https://api.render.com';
const INTEGRATION_FLYIO_API_URL = 'https://api.fly.io/graphql';
const INTEGRATION_OPTIONS = [
{
name: 'Heroku',
slug: 'heroku',
image: 'Heroku',
image: 'Heroku.png',
isAvailable: true,
type: 'oauth2',
type: 'oauth',
clientId: CLIENT_ID_HEROKU,
docsLink: ''
},
{
name: 'Vercel',
slug: 'vercel',
image: 'Vercel',
image: 'Vercel.png',
isAvailable: true,
type: 'vercel',
type: 'oauth',
clientId: '',
clientSlug: CLIENT_SLUG_VERCEL,
docsLink: ''
@ -56,26 +62,43 @@ const INTEGRATION_OPTIONS = [
{
name: 'Netlify',
slug: 'netlify',
image: 'Netlify',
image: 'Netlify.png',
isAvailable: true,
type: 'oauth2',
type: 'oauth',
clientId: CLIENT_ID_NETLIFY,
docsLink: ''
},
{
name: 'GitHub',
slug: 'github',
image: 'GitHub',
image: 'GitHub.png',
isAvailable: true,
type: 'oauth2',
type: 'oauth',
clientId: CLIENT_ID_GITHUB,
docsLink: ''
},
{
name: 'Render',
slug: 'render',
image: 'Render.png',
isAvailable: true,
type: 'pat',
clientId: '',
docsLink: ''
},
{
name: 'Fly.io',
slug: 'flyio',
image: 'Flyio.svg',
isAvailable: true,
type: 'pat',
clientId: '',
docsLink: ''
},
{
name: 'Google Cloud Platform',
slug: 'gcp',
image: 'Google Cloud Platform',
image: 'Google Cloud Platform.png',
isAvailable: false,
type: '',
clientId: '',
@ -84,7 +107,7 @@ const INTEGRATION_OPTIONS = [
{
name: 'Amazon Web Services',
slug: 'aws',
image: 'Amazon Web Services',
image: 'Amazon Web Services.png',
isAvailable: false,
type: '',
clientId: '',
@ -93,7 +116,7 @@ const INTEGRATION_OPTIONS = [
{
name: 'Microsoft Azure',
slug: 'azure',
image: 'Microsoft Azure',
image: 'Microsoft Azure.png',
isAvailable: false,
type: '',
clientId: '',
@ -102,7 +125,7 @@ const INTEGRATION_OPTIONS = [
{
name: 'Travis CI',
slug: 'travisci',
image: 'Travis CI',
image: 'Travis CI.png',
isAvailable: false,
type: '',
clientId: '',
@ -111,7 +134,7 @@ const INTEGRATION_OPTIONS = [
{
name: 'Circle CI',
slug: 'circleci',
image: 'Circle CI',
image: 'Circle CI.png',
isAvailable: false,
type: '',
clientId: '',
@ -124,6 +147,8 @@ export {
INTEGRATION_VERCEL,
INTEGRATION_NETLIFY,
INTEGRATION_GITHUB,
INTEGRATION_RENDER,
INTEGRATION_FLYIO,
INTEGRATION_SET,
INTEGRATION_OAUTH2,
INTEGRATION_HEROKU_TOKEN_URL,
@ -133,5 +158,7 @@ export {
INTEGRATION_HEROKU_API_URL,
INTEGRATION_VERCEL_API_URL,
INTEGRATION_NETLIFY_API_URL,
INTEGRATION_RENDER_API_URL,
INTEGRATION_FLYIO_API_URL,
INTEGRATION_OPTIONS
};

View File

@ -10,4 +10,4 @@ We're still early with integrations, but expect more soon.
View all available integrations and their guides
</Card>
![integrations](../../images/project-integrations.png)
![integrations](../../images/integrations.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 418 KiB

View File

@ -2,4 +2,34 @@
title: "Fly.io"
---
Coming soon.
Prerequisites:
- Set up and add envars to [Infisical Cloud](https://app.infisical.com)
## Navigate to your project's integrations tab
![integrations](../../images/integrations.png)
## Authorize Infisical for Fly.io
Obtain a Fly.io access token in Access Tokens
![integrations fly dashboard](../../images/integrations-flyio-dashboard.png)
![integrations fly token](../../images/integrations-flyio-token.png)
Press on the Fly.io tile and input your Fly.io access token to grant Infisical access to your Fly.io account.
![integrations fly authorization](../../images/integrations-flyio-auth.png)
<Info>
If this is your project's first cloud integration, then you'll have to grant
Infisical access to your project's environment variables. Although this step
breaks E2EE, it's necessary for Infisical to sync the environment variables to
the cloud platform.
</Info>
## Start integration
Select which Infisical environment secrets you want to sync to which Fly.io app and press start integration to start syncing secrets to Fly.io.
![integrations fly](../../images/integrations-flyio.png)

View File

@ -2,4 +2,34 @@
title: "Render"
---
Coming soon.
Prerequisites:
- Set up and add envars to [Infisical Cloud](https://app.infisical.com)
## Navigate to your project's integrations tab
![integrations](../../images/integrations.png)
## Enter your Render API Key
Obtain a Render API Key in your Render Account Settings > API Keys.
![integrations render dashboard](../../images/integrations-render-dashboard.png)
![integrations render token](../../images/integrations-render-token.png)
Press on the Render tile and input your Render API Key to grant Infisical access to your Render account.
![integrations render authorization](../../images/integrations-render-auth.png)
<Info>
If this is your project's first cloud integration, then you'll have to grant
Infisical access to your project's environment variables. Although this step
breaks E2EE, it's necessary for Infisical to sync the environment variables to
the cloud platform.
</Info>
## Start integration
Select which Infisical environment secrets you want to sync to which Render service and press start integration to start syncing secrets to Render.
![integrations heroku](../../images/integrations-render.png)

View File

@ -20,4 +20,4 @@ Press on the Vercel tile and grant Infisical access to your Vercel account.
Select which Infisical environment secrets you want to sync to which Vercel app and environment. Lastly, press start integration to start syncing secrets to Vercel.
![integrations vercel](../../images/integrations-vercel.png)
![integrations vercel](../../images/integrations-vercel.png)

View File

@ -1,5 +1,5 @@
---
title: 'Overview'
title: "Overview"
---
Integrations allow environment variables to be synced from Infisical into your local development workflow, CI/CD pipelines, and production infrastructure.
@ -14,6 +14,8 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi
| [Heroku](/integrations/cloud/heroku) | Cloud | Available |
| [Vercel](/integrations/cloud/vercel) | Cloud | Available |
| [Netlify](/integrations/cloud/netlify) | Cloud | Available |
| [Render](/integrations/cloud/render) | Cloud | Available |
| [Fly.io](/integrations/cloud/flyio) | Cloud | Available |
| [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available |
| [React](/integrations/frameworks/react) | Framework | Available |
| [Vue](/integrations/frameworks/vue) | Framework | Available |
@ -29,8 +31,6 @@ Missing an integration? Throw in a [request](https://github.com/Infisical/infisi
| [Flask](/integrations/frameworks/flask) | Framework | Available |
| [Laravel](/integrations/frameworks/laravel) | Framework | Available |
| [Ruby on Rails](/integrations/frameworks/rails) | Framework | Available |
| [Render](/integrations/cloud/render) | Cloud | Coming soon |
| [Fly.io](/integrations/cloud/flyio) | Cloud | Coming soon |
| AWS | Cloud | Coming soon |
| GCP | Cloud | Coming soon |
| Azure | Cloud | Coming soon |

View File

@ -27,7 +27,7 @@
"http://localhost:8080"
],
"auth": {
"method": "api-key",
"method": "key",
"name": "X-API-KEY"
}
},

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -4,12 +4,23 @@ import { Dialog, Transition } from '@headlessui/react';
import Button from '../buttons/Button';
interface IntegrationOption {
clientId: string;
clientSlug?: string; // vercel-integration specific
docsLink: string;
image: string;
isAvailable: boolean;
name: string;
slug: string;
type: string;
}
type Props = {
isOpen: boolean;
closeModal: () => void;
selectedIntegrationOption: never[] | null;
selectedIntegrationOption: IntegrationOption | null;
handleBotActivate: () => Promise<void>;
handleIntegrationOption: (arg: { integrationOption: never[] }) => void;
integrationOptionPress: (integrationOption: IntegrationOption) => void;
};
const ActivateBotDialog = ({
@ -17,7 +28,7 @@ const ActivateBotDialog = ({
closeModal,
selectedIntegrationOption,
handleBotActivate,
handleIntegrationOption
integrationOptionPress
}: Props) => {
const { t } = useTranslation();
@ -28,10 +39,10 @@ const ActivateBotDialog = ({
// type check
if (!selectedIntegrationOption) return;
// 2. start integration
await handleIntegrationOption({
integrationOption: selectedIntegrationOption
});
// 2. start integration or probe for PAT
integrationOptionPress(selectedIntegrationOption);
} catch (err) {
console.log(err);
}

View File

@ -1,45 +1,60 @@
import { Fragment } from "react";
import { Fragment, useState } from "react";
import { Dialog, Transition } from "@headlessui/react";
import Button from "../buttons/Button";
import InputField from "../InputField";
interface IntegrationOption {
clientId: string;
clientSlug?: string; // vercel-integration specific
docsLink: string;
image: string;
isAvailable: boolean;
name: string;
slug: string;
type: string;
}
type Props = {
isOpen: boolean;
closeModal: () => void;
selectedIntegrationOption: string;
handleBotActivate: () => void;
handleIntegrationOption: (arg:{integrationOption:string})=>void;
selectedIntegrationOption: IntegrationOption | null
handleIntegrationOption: (arg:{
integrationOption: IntegrationOption,
accessToken?: string;
})=>void;
};
const IntegrationAccessTokenDialog = ({
isOpen,
closeModal,
selectedIntegrationOption,
handleBotActivate,
handleIntegrationOption
}:Props) => {
const [accessToken, setAccessToken] = useState('');
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const submit = async () => {
try {
// 1. activate bot
await handleBotActivate();
// 2. start integration
await handleIntegrationOption({
integrationOption: selectedIntegrationOption
});
if (selectedIntegrationOption && accessToken !== '') {
handleIntegrationOption({
integrationOption: selectedIntegrationOption,
accessToken
});
closeModal();
setAccessToken('');
}
} catch (err) {
console.log(err);
}
closeModal();
}
return (
<div>
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={closeModal}>
<Dialog as="div" className="relative z-10" onClose={() => {
console.log('onClose');
closeModal();
}}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@ -67,28 +82,30 @@ const IntegrationAccessTokenDialog = ({
as="h3"
className="text-lg font-medium leading-6 text-gray-400"
>
Grant Infisical access to your secrets
{`Enter your ${selectedIntegrationOption?.name} API Key`}
</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.
{`This integration requires you to obtain an API key from ${selectedIntegrationOption?.name ?? ''} and store it with Infisical.`}
</p>
</div>
<div className="mt-6 max-w-max">
{/* <Button
onButtonPressed={submit}
color="mineshaft"
text="Grant access"
size="md"
/> */}
<InputField
label="Access token"
onChangeHandler={() => {}}
label="API Key"
onChangeHandler={setAccessToken}
type="varName"
value="Hello"
value={accessToken}
placeholder=""
isRequired
/>
<div className="mt-4">
<Button
onButtonPressed={submit}
color="mineshaft"
text="Connect"
size="md"
/>
</div>
</div>
</Dialog.Panel>
</Transition.Child>

View File

@ -1,38 +1,42 @@
import Image from 'next/image';
import { useRouter } from 'next/router';
import { faCheck, faX } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import deleteIntegrationAuth from '../../pages/api/integrations/DeleteIntegrationAuth';
interface CloudIntegrationOption {
interface IntegrationOption {
clientId: string;
clientSlug?: string; // vercel-integration specific
docsLink: string;
image: string;
isAvailable: boolean;
name: string;
type: string;
clientId: string;
docsLink: string;
slug: string;
type: string;
}
interface IntegrationAuth {
_id: string;
integration: string;
workspace: string;
createdAt: string;
updatedAt: string;
}
interface Props {
cloudIntegrationOption: CloudIntegrationOption;
setSelectedIntegrationOption: (cloudIntegration: CloudIntegrationOption) => void;
integrationOptionPress: (cloudIntegrationOption: CloudIntegrationOption) => void;
cloudIntegrationOption: IntegrationOption;
setSelectedIntegrationOption: (cloudIntegration: IntegrationOption) => void;
integrationOptionPress: (cloudIntegrationOption: IntegrationOption) => void;
integrationAuths: IntegrationAuth[];
handleDeleteIntegrationAuth: (args: { integrationAuth: IntegrationAuth }) => void;
}
const CloudIntegration = ({
cloudIntegrationOption,
setSelectedIntegrationOption,
integrationOptionPress,
integrationAuths
integrationAuths,
handleDeleteIntegrationAuth
}: Props) => {
const router = useRouter();
return integrationAuths ? (
<div
onKeyDown={() => null}
@ -51,7 +55,7 @@ const CloudIntegration = ({
key={cloudIntegrationOption.name}
>
<Image
src={`/images/integrations/${cloudIntegrationOption.name}.png`}
src={`/images/integrations/${cloudIntegrationOption.image}`}
height={70}
width={70}
alt="integration logo"
@ -71,24 +75,26 @@ const CloudIntegration = ({
{cloudIntegrationOption.isAvailable &&
integrationAuths
.map((authorization) => authorization.integration)
.includes(cloudIntegrationOption.name.toLowerCase()) && (
.includes(cloudIntegrationOption.slug) && (
<div className="absolute group z-40 top-0 right-0 flex flex-row">
<div
onKeyDown={() => null}
role="button"
tabIndex={0}
onClick={(event) => {
onClick={async (event) => {
event.stopPropagation();
deleteIntegrationAuth({
const deletedIntegrationAuth = await deleteIntegrationAuth({
integrationAuthId: integrationAuths
.filter(
(authorization) =>
authorization.integration === cloudIntegrationOption.name.toLowerCase()
authorization.integration === cloudIntegrationOption.slug
)
.map((authorization) => authorization._id)[0]
});
router.reload();
handleDeleteIntegrationAuth({
integrationAuth: deletedIntegrationAuth
});
}}
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"
>

View File

@ -2,20 +2,31 @@ import { useTranslation } from 'next-i18next';
import CloudIntegration from './CloudIntegration';
interface CloudIntegrationOption {
interface IntegrationOption {
clientId: string;
clientSlug?: string; // vercel-integration specific
docsLink: string;
image: string;
isAvailable: boolean;
name: string;
type: string;
clientId: string;
docsLink: string;
slug: string;
type: string;
}
interface IntegrationAuth {
_id: string;
integration: string;
workspace: string;
createdAt: string;
updatedAt: string;
}
interface Props {
cloudIntegrationOptions: CloudIntegrationOption[];
cloudIntegrationOptions: IntegrationOption[];
setSelectedIntegrationOption: () => void;
integrationOptionPress: () => void;
integrationAuths: any;
integrationOptionPress: (integrationOption: IntegrationOption) => void;
integrationAuths: IntegrationAuth[];
handleDeleteIntegrationAuth: (args: { integrationAuth: IntegrationAuth }) => void;
}
const CloudIntegrationSection = ({
@ -23,6 +34,7 @@ const CloudIntegrationSection = ({
setSelectedIntegrationOption,
integrationOptionPress,
integrationAuths,
handleDeleteIntegrationAuth
}: Props) => {
const { t } = useTranslation();
@ -45,6 +57,7 @@ const CloudIntegrationSection = ({
setSelectedIntegrationOption={setSelectedIntegrationOption}
integrationOptionPress={integrationOptionPress}
integrationAuths={integrationAuths}
handleDeleteIntegrationAuth={handleDeleteIntegrationAuth}
key={`cloud-integration-${cloudIntegrationOption.slug}`}
/>
))}

View File

@ -13,29 +13,45 @@ import deleteIntegration from '../../pages/api/integrations/DeleteIntegration';
import getIntegrationApps from '../../pages/api/integrations/GetIntegrationApps';
import updateIntegration from '../../pages/api/integrations/updateIntegration';
interface TIntegration {
interface Integration {
_id: string;
app?: string;
target?: string;
isActive: boolean;
app: string | null;
appId: string | null;
createdAt: string;
updatedAt: string;
environment: string;
integration: string;
targetEnvironment: string;
workspace: string;
integrationAuth: string;
isActive: boolean;
context: string;
}
interface IntegrationApp {
name: string;
siteId?: string;
appId?: string;
owner?: string;
}
type Props = {
integration: TIntegration;
integration: Integration;
integrations: Integration[];
setIntegrations: any;
bot: any;
setBot: any;
environments: Array<{ name: string; slug: string }>;
handleDeleteIntegration: (args: { integration: Integration }) => void;
};
const Integration = ({ integration, environments = [] }: Props) => {
const IntegrationTile = ({
integration,
integrations,
bot,
setBot,
setIntegrations,
environments = [],
handleDeleteIntegration
}: Props) => {
// set initial environment. This find will only execute when component is mounting
const [integrationEnvironment, setIntegrationEnvironment] = useState<Props['environments'][0]>(
environments.find(({ slug }) => slug === integration.environment) || {
@ -46,8 +62,7 @@ const Integration = ({ integration, environments = [] }: Props) => {
const router = useRouter();
const [apps, setApps] = useState<IntegrationApp[]>([]); // integration app objects
const [integrationApp, setIntegrationApp] = useState(''); // integration app name
const [integrationTarget, setIntegrationTarget] = useState(''); // vercel-specific integration param
const [integrationContext, setIntegrationContext] = useState(''); // netlify-specific integration param
const [integrationTargetEnvironment, setIntegrationTargetEnvironment] = useState('');
useEffect(() => {
const loadIntegration = async () => {
@ -55,21 +70,23 @@ const Integration = ({ integration, environments = [] }: Props) => {
const tempApps: [IntegrationApp] = await getIntegrationApps({
integrationAuthId: integration.integrationAuth
});
setApps(tempApps);
setIntegrationApp(integration.app ? integration.app : tempApps[0].name);
switch (integration.integration) {
case 'vercel':
setIntegrationTarget(
integration?.target
? integration.target.charAt(0).toUpperCase() + integration.target.substring(1)
: 'Development'
setIntegrationTargetEnvironment(
integration?.targetEnvironment
? integration.targetEnvironment.charAt(0).toUpperCase() + integration.targetEnvironment.substring(1)
: 'Development'
);
break;
case 'netlify':
setIntegrationContext(
integration?.context ? contextNetlifyMapping[integration.context] : 'Local development'
setIntegrationTargetEnvironment(
integration?.targetEnvironment
? contextNetlifyMapping[integration.targetEnvironment]
: 'Local development'
);
break;
default:
@ -79,9 +96,45 @@ const Integration = ({ integration, environments = [] }: Props) => {
loadIntegration();
}, []);
const handleStartIntegration = async () => {
const reformatTargetEnvironment = (targetEnvironment: string) => {
switch (integration.integration) {
case 'vercel':
return targetEnvironment.toLowerCase();
case 'netlify':
return reverseContextNetlifyMapping[targetEnvironment];
default:
return null;
}
}
try {
const siteApp = apps.find((app) => app.name === integrationApp); // obj or undefined
const appId = siteApp?.appId ?? null;
const owner = siteApp?.owner ?? null;
// return updated integration
const updatedIntegration = await updateIntegration({
integrationId: integration._id,
environment: integrationEnvironment.slug,
isActive: true,
app: integrationApp,
appId,
targetEnvironment: reformatTargetEnvironment(integrationTargetEnvironment),
owner
});
setIntegrations(
integrations.map((i) => i._id === updatedIntegration._id ? updatedIntegration : i)
);
} catch (err) {
console.error(err);
}
}
// eslint-disable-next-line @typescript-eslint/no-shadow
const renderIntegrationSpecificParams = (integration: TIntegration) => {
const renderIntegrationSpecificParams = (integration: Integration) => {
try {
switch (integration.integration) {
case 'vercel':
@ -90,8 +143,8 @@ const Integration = ({ integration, environments = [] }: Props) => {
<div className="text-gray-400 text-xs font-semibold mb-2 w-60">ENVIRONMENT</div>
<ListBox
data={!integration.isActive ? ['Development', 'Preview', 'Production'] : null}
isSelected={integrationTarget}
onChange={setIntegrationTarget}
isSelected={integrationTargetEnvironment}
onChange={setIntegrationTargetEnvironment}
isFull
/>
</div>
@ -106,8 +159,8 @@ const Integration = ({ integration, environments = [] }: Props) => {
? ['Production', 'Deploy previews', 'Branch deploys', 'Local development']
: null
}
isSelected={integrationContext}
onChange={setIntegrationContext}
isSelected={integrationTargetEnvironment}
onChange={setIntegrationTargetEnvironment}
/>
</div>
);
@ -172,38 +225,16 @@ const Integration = ({ integration, environments = [] }: Props) => {
) : (
<Button
text="Start Integration"
onButtonPressed={async () => {
const siteApp = apps.find((app) => app.name === integrationApp); // obj or undefined
const siteId = siteApp?.siteId ?? null;
const owner = siteApp?.owner ?? null;
await updateIntegration({
integrationId: integration._id,
environment: integrationEnvironment.slug,
app: integrationApp,
isActive: true,
target: integrationTarget ? integrationTarget.toLowerCase() : null,
context: integrationContext
? reverseContextNetlifyMapping[integrationContext]
: null,
siteId,
owner
});
router.reload();
}}
onButtonPressed={() => handleStartIntegration()}
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();
}}
onButtonPressed={() => handleDeleteIntegration({
integration
})}
color="red"
size="icon-md"
icon={faX}
@ -214,4 +245,4 @@ const Integration = ({ integration, environments = [] }: Props) => {
);
};
export default Integration;
export default IntegrationTile;

View File

@ -1,37 +1,62 @@
import guidGenerator from '@app/components/utilities/randomId';
import Integration from './Integration';
import IntegrationTile from './Integration';
interface Props {
integrations: any;
setIntegrations: any;
bot: any;
setBot: any;
environments: Array<{ name: string; slug: string }>;
handleDeleteIntegration: (args: { integration: Integration }) => void;
}
interface IntegrationType {
interface Integration {
_id: string;
app?: string;
isActive: boolean;
app: string | null;
appId: string | null;
createdAt: string;
updatedAt: string;
environment: string;
integration: string;
targetEnvironment: string;
workspace: string;
integrationAuth: string;
isActive: boolean;
context: string;
}
const ProjectIntegrationSection = ({ integrations, environments = [] }: Props) =>
const ProjectIntegrationSection = ({
integrations,
setIntegrations,
bot,
setBot,
environments = [],
handleDeleteIntegration
}: Props) =>
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.
Manage integrations with third-party services.
</p>
</div>
{integrations.map((integration: IntegrationType) => (
<Integration key={guidGenerator()} integration={integration} environments={environments} />
))}
{integrations.map((integration: Integration) => {
console.log('IntegrationSection integration: ', integration);
return (
<IntegrationTile
key={`integration-${integration._id.toString()}`}
integration={integration}
integrations={integrations}
bot={bot}
setBot={setBot}
setIntegrations={setIntegrations}
environments={environments}
handleDeleteIntegration={handleDeleteIntegration}
/>
);
})}
</div>
) : (
<div />
);
export default ProjectIntegrationSection;
export default ProjectIntegrationSection;

View File

@ -8,7 +8,7 @@ interface BotKey {
interface Props {
botId: string;
isActive: boolean;
botKey: BotKey;
botKey?: BotKey;
}
/**

View File

@ -17,7 +17,7 @@ const deleteIntegration = ({ integrationId }: Props) =>
}
}).then(async (res) => {
if (res && res.status === 200) {
return (await res.json()).workspace;
return (await res.json()).integration;
}
console.log('Failed to delete an integration');
return undefined;

View File

@ -17,7 +17,7 @@ const deleteIntegrationAuth = ({ integrationAuthId }: Props) =>
}
}).then(async (res) => {
if (res && res.status === 200) {
return res;
return (await res.json()).integrationAuth;
}
console.log('Failed to delete an integration authorization');
return undefined;

View File

@ -0,0 +1,31 @@
import SecurityClient from '@app/components/utilities/SecurityClient';
interface Props {
integrationAuthId: string;
}
/**
* This route creates a new integration based on the integration authorization with id [integrationAuthId]
* @param {Object} obj
* @param {String} obj.accessToken - id of integration authorization for which to create the integration
* @returns
*/
const createIntegration = ({
integrationAuthId
}: Props) =>
SecurityClient.fetchCall('/api/v1/integration', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
integrationAuthId
})
}).then(async (res) => {
if (res && res.status === 200) {
return (await res.json()).integration;
}
console.log('Failed to create integration');
return undefined;
});
export default createIntegration;

View File

@ -0,0 +1,42 @@
import SecurityClient from '@app/components/utilities/SecurityClient';
interface Props {
workspaceId: string | null;
integration: string | undefined;
accessToken: string;
}
/**
* This route creates a new integration authorization for integration [integration]
* that requires the user to input their access token manually (e.g. Render). It
* saves access token [accessToken] under that integration for workspace with id
* [workspaceId].
* @param {Object} obj
* @param {String} obj.workspaceId - id of workspace to authorize integration for
* @param {String} obj.integration - integration
* @param {String} obj.accessToken - access token to save
* @returns
*/
const saveIntegrationAccessToken = ({
workspaceId,
integration,
accessToken
}: Props) =>
SecurityClient.fetchCall(`/api/v1/integration-auth/access-token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
workspaceId,
integration,
accessToken
})
}).then(async (res) => {
if (res && res.status === 200) {
return (await res.json()).integrationAuth;
}
console.log('Failed to save integration access token');
return undefined;
});
export default saveIntegrationAccessToken;

View File

@ -6,32 +6,29 @@ import SecurityClient from '@app/components/utilities/SecurityClient';
* [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
* @param {String} obj.environment - project environment to push secrets from
* @param {String} obj.app - name of app
* @param {String} obj.appId - (optional) app ID for integration
* @param {String} obj.targetEnvironment - target environment for integration
* @param {String} obj.owner - (optional) owner login of repo for GitHub integration
* @returns
*/
const updateIntegration = ({
integrationId,
app,
environment,
isActive,
target,
context,
siteId,
environment,
app,
appId,
targetEnvironment,
owner
}: {
integrationId: string;
app: string;
environment: string;
isActive: boolean;
target: string | null;
context: string | null;
siteId: string | null;
environment: string;
app: string;
appId: string | null;
targetEnvironment: string | null;
owner: string | null;
}) =>
SecurityClient.fetchCall(`/api/v1/integration/${integrationId}`, {
@ -43,14 +40,13 @@ const updateIntegration = ({
app,
environment,
isActive,
target,
context,
siteId,
appId,
targetEnvironment,
owner
})
}).then(async (res) => {
if (res && res.status === 200) {
return res;
return (await res.json()).integration;
}
console.log('Failed to start an integration');
return undefined;

View File

@ -7,6 +7,7 @@ import { useTranslation } from 'next-i18next';
import frameworkIntegrationOptions from 'public/json/frameworkIntegrations.json';
import ActivateBotDialog from '@app/components/basic/dialog/ActivateBotDialog';
import IntegrationAccessTokenDialog from '@app/components/basic/dialog/IntegrationAccessTokenDialog';
import CloudIntegrationSection from '@app/components/integrations/CloudIntegrationSection';
import FrameworkIntegrationSection from '@app/components/integrations/FrameworkIntegrationSection';
import IntegrationSection from '@app/components/integrations/IntegrationSection';
@ -19,27 +20,63 @@ import {
} from '../../components/utilities/cryptography/crypto';
import getBot from '../api/bot/getBot';
import setBotActiveStatus from '../api/bot/setBotActiveStatus';
import createIntegration from '../api/integrations/createIntegration';
import deleteIntegration from '../api/integrations/DeleteIntegration';
import getIntegrationOptions from '../api/integrations/GetIntegrationOptions';
import getWorkspaceAuthorizations from '../api/integrations/getWorkspaceAuthorizations';
import getWorkspaceIntegrations from '../api/integrations/getWorkspaceIntegrations';
import saveIntegrationAccessToken from '../api/integrations/saveIntegrationAccessToken';
import getAWorkspace from '../api/workspace/getAWorkspace';
import getLatestFileKey from '../api/workspace/getLatestFileKey';
interface IntegrationAuth {
_id: string;
integration: string;
workspace: string;
createdAt: string;
updatedAt: string;
}
interface Integration {
_id: string;
isActive: boolean;
app: string | null;
appId: string | null;
createdAt: string;
updatedAt: string;
environment: string;
integration: string;
targetEnvironment: string;
workspace: string;
integrationAuth: string;
}
interface IntegrationOption {
clientId: string;
clientSlug?: string; // vercel-integration specific
docsLink: string;
image: string;
isAvailable: boolean;
name: string;
slug: string;
type: string;
}
export default function Integrations() {
const [cloudIntegrationOptions, setCloudIntegrationOptions] = useState([]);
const [integrationAuths, setIntegrationAuths] = useState([]);
const [integrationAuths, setIntegrationAuths] = useState<IntegrationAuth[]>([]);
const [environments, setEnvironments] = useState<
{
name: string;
slug: string;
}[]
>([]);
const [integrations, setIntegrations] = useState([]);
const [integrations, setIntegrations] = useState<Integration[]>([]);
// TODO: These will have its type when migratiing towards react-query
const [bot, setBot] = useState<any>(null);
const [isActivateBotDialogOpen, setIsActivateBotDialogOpen] = useState(false);
// const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(true);
const [selectedIntegrationOption, setSelectedIntegrationOption] = useState(null);
const [isIntegrationAccessTokenDialogOpen, setIntegrationAccessTokenDialogOpen] = useState(false);
const [selectedIntegrationOption, setSelectedIntegrationOption] = useState<IntegrationOption | null>(null);
const router = useRouter();
const workspaceId = router.query.id as string;
@ -72,7 +109,7 @@ export default function Integrations() {
// get project bot
setBot(await getBot({ workspaceId }));
} catch (err) {
console.log(err);
console.error(err);
}
})();
}, []);
@ -118,7 +155,7 @@ export default function Integrations() {
(
await setBotActiveStatus({
botId: bot._id,
isActive: !bot.isActive,
isActive: true,
botKey
})
).bot
@ -128,9 +165,9 @@ export default function Integrations() {
console.error(err);
}
};
/**
* Start integration for a given integration option [integrationOption]
* Handle integration option authorization for a given integration option [integrationOption]
* @param {Object} obj
* @param {Object} obj.integrationOption - an integration option
* @param {String} obj.name
@ -138,45 +175,68 @@ export default function Integrations() {
* @param {String} obj.docsLink
* @returns
*/
const handleIntegrationOption = async ({ integrationOption }: { integrationOption: any }) => {
const handleIntegrationOption = async ({
integrationOption,
accessToken
}: {
integrationOption: IntegrationOption,
accessToken?: string;
}) => {
try {
// generate CSRF token for OAuth2 code-token exchange integrations
const state = crypto.randomBytes(16).toString('hex');
localStorage.setItem('latestCSRFToken', state);
if (integrationOption.type === 'oauth') {
// integration is of type OAuth
switch (integrationOption.name) {
case 'Heroku':
window.location.assign(
`https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`
);
break;
case 'Vercel':
window.location.assign(
`https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`
);
break;
case 'Netlify':
window.location.assign(
`https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify`
);
break;
case 'GitHub':
window.location.assign(
`https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/github&state=${state}`
);
break;
default:
break;
// case 'Fly.io':
// console.log('fly.io');
// setIntegrationAccessTokenDialogOpen(true);
// break;
// generate CSRF token for OAuth2 code-token exchange integrations
const state = crypto.randomBytes(16).toString('hex');
localStorage.setItem('latestCSRFToken', state);
switch (integrationOption.slug) {
case 'heroku':
window.location.assign(
`https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`
);
break;
case 'vercel':
window.location.assign(
`https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`
);
break;
case 'netlify':
window.location.assign(
`https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/netlify`
);
break;
case 'github':
window.location.assign(
`https://github.com/login/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=repo&redirect_uri=${window.location.origin}/github&state=${state}`
);
break;
default:
break;
}
return;
} if (integrationOption.type === 'pat') {
// integration is of type personal access token
const integrationAuth = await saveIntegrationAccessToken({
workspaceId: localStorage.getItem('projectData.id'),
integration: integrationOption.slug,
accessToken: accessToken ?? ''
});
setIntegrationAuths([...integrationAuths, integrationAuth])
const integration = await createIntegration({
integrationAuthId: integrationAuth._id
});
setIntegrations([...integrations, integration]);
return;
}
} catch (err) {
console.log(err);
}
};
/**
* Open dialog to activate bot if bot is not active.
* Otherwise, start integration [integrationOption]
@ -186,20 +246,94 @@ export default function Integrations() {
* @param {String} integrationOption.docsLink
* @returns
*/
const integrationOptionPress = (integrationOption: any) => {
const integrationOptionPress = async (integrationOption: IntegrationOption) => {
try {
if (bot.isActive) {
// case: bot is active -> proceed with integration
const integrationAuthX = integrationAuths.find((integrationAuth) => integrationAuth.integration === integrationOption.slug);
if (!integrationAuthX) {
// case: integration has not been authorized before
if (integrationOption.type === 'pat') {
// case: integration requires user to input their personal access token for that integration
setIntegrationAccessTokenDialogOpen(true);
return;
}
// case: integration does not require user to input their personal access token (i.e. it's an OAuth2 integration)
handleIntegrationOption({ integrationOption });
return;
}
// case: bot is not active -> open modal to activate bot
setIsActivateBotDialogOpen(true);
// case: integration has been authorized before
// -> create new integration
const integration = await createIntegration({
integrationAuthId: integrationAuthX._id
});
setIntegrations([...integrations, integration]);
} catch (err) {
console.error(err);
}
};
/**
* Handle deleting integration authorization [integrationAuth] and corresponding integrations from state where applicable
* @param {Object} obj
* @param {IntegrationAuth} obj.integrationAuth - integrationAuth to delete
*/
const handleDeleteIntegrationAuth = async ({ integrationAuth: deletedIntegrationAuth }: { integrationAuth: IntegrationAuth }) => {
try {
const newIntegrations = integrations.filter((integration) => integration.integrationAuth !== deletedIntegrationAuth._id);
setIntegrationAuths(integrationAuths.filter((integrationAuth) => integrationAuth._id !== deletedIntegrationAuth._id));
setIntegrations(newIntegrations);
// handle updating bot
if (newIntegrations.length < 1) {
// case: no integrations left
setBot(
(
await setBotActiveStatus({
botId: bot._id,
isActive: false
})
).bot
);
}
} catch (err) {
console.error(err);
}
}
/**
* Handle deleting integration [integration]
* @param {Object} obj
* @param {Integration} obj.integration - integration to delete
*/
const handleDeleteIntegration = async ({ integration }: { integration: Integration }) => {
try {
const deletedIntegration = await deleteIntegration({
integrationId: integration._id
});
const newIntegrations = integrations.filter((i) => i._id !== deletedIntegration._id);
setIntegrations(newIntegrations);
// handle updating bot
if (newIntegrations.length < 1) {
// case: no integrations left
setBot(
(
await setBotActiveStatus({
botId: bot._id,
isActive: false
})
).bot
);
}
} catch (err) {
console.error(err);
}
}
return (
<div className="bg-bunker-800 max-h-screen flex flex-col justify-between text-white">
@ -217,22 +351,37 @@ export default function Integrations() {
closeModal={() => setIsActivateBotDialogOpen(false)}
selectedIntegrationOption={selectedIntegrationOption}
handleBotActivate={handleBotActivate}
handleIntegrationOption={handleIntegrationOption}
integrationOptionPress={integrationOptionPress}
/>
{/* <IntegrationAccessTokenDialog
<IntegrationAccessTokenDialog
isOpen={isIntegrationAccessTokenDialogOpen}
closeModal={() => setIntegrationAccessTokenDialogOpen(false)}
selectedIntegrationOption={selectedIntegrationOption}
handleBotActivate={handleBotActivate}
handleIntegrationOption={handleIntegrationOption}
/> */}
<IntegrationSection integrations={integrations} environments={environments} />
/>
<IntegrationSection
integrations={integrations}
setIntegrations={setIntegrations}
bot={bot}
setBot={setBot}
environments={environments}
handleDeleteIntegration={handleDeleteIntegration}
/>
{cloudIntegrationOptions.length > 0 && bot ? (
<CloudIntegrationSection
cloudIntegrationOptions={cloudIntegrationOptions}
setSelectedIntegrationOption={setSelectedIntegrationOption as any}
integrationOptionPress={integrationOptionPress as any}
integrationOptionPress={(integrationOption: IntegrationOption) => {
if (!bot.isActive) {
// case: bot is not active -> open modal to activate bot
setIsActivateBotDialogOpen(true);
return;
}
integrationOptionPress(integrationOption)
}}
integrationAuths={integrationAuths}
handleDeleteIntegrationAuth={handleDeleteIntegrationAuth}
/>
) : (
<div />