From 0fee4af51141253c8ca43530c28e58285a119de3 Mon Sep 17 00:00:00 2001 From: Akshat Date: Mon, 11 Dec 2023 16:36:31 +0530 Subject: [PATCH] add(integrations): salesforce --- .github/workflows/build-examples.yml | 1 + .../.github/workflows/terraform.yml | 81 +++++++ .../aws-lambda-salesforce-webhook/.gitignore | 12 + .../aws-lambda-salesforce-webhook/README.md | 1 + .../aws-lambda-salesforce-webhook/main.tf | 24 ++ .../aws-lambda-salesforce-webhook/outputs.tf | 9 + .../.gitignore | 10 + .../package.json | 40 ++++ .../readme.md | 1 + .../scripts/build-layers.sh | 17 ++ .../src/index.ts | 6 + .../src/integration/index.ts | 207 ++++++++++++++++++ .../src/integration/salesforce-types.ts | 67 ++++++ .../terraform/apiGateway.tf | 64 ++++++ .../terraform/config/.gitkeep | 0 .../terraform/config/example.tfvars | 8 + .../terraform/iam.tf | 52 +++++ .../terraform/lambda.tf | 44 ++++ .../terraform/locals.tf | 16 ++ .../terraform/outputs.tf | 4 + .../terraform/provider.tf | 17 ++ .../terraform/variables.tf | 26 +++ .../tsconfig.json | 16 ++ .../variables.tf | 33 +++ .../CHANGELOG.md | 1 + .../package.json | 23 ++ .../src/index.ts | 207 ++++++++++++++++++ .../src/salesforce-types.ts | 67 ++++++ .../test/salesforce.test.ts | 188 ++++++++++++++++ .../tsconfig.build.json | 8 + templates/scripts/steps/catalog.ts | 17 ++ 31 files changed, 1267 insertions(+) create mode 100644 examples/aws-lambda-salesforce-webhook/.github/workflows/terraform.yml create mode 100644 examples/aws-lambda-salesforce-webhook/.gitignore create mode 100644 examples/aws-lambda-salesforce-webhook/README.md create mode 100644 examples/aws-lambda-salesforce-webhook/main.tf create mode 100644 examples/aws-lambda-salesforce-webhook/outputs.tf create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/.gitignore create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/package.json create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/readme.md create mode 100755 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/scripts/build-layers.sh create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/index.ts create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/index.ts create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/salesforce-types.ts create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/apiGateway.tf create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/config/.gitkeep create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/config/example.tfvars create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/iam.tf create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/lambda.tf create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/locals.tf create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/outputs.tf create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/provider.tf create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/variables.tf create mode 100644 examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/tsconfig.json create mode 100644 examples/aws-lambda-salesforce-webhook/variables.tf create mode 100644 packages/beta/indent-integration-salesforce/CHANGELOG.md create mode 100644 packages/beta/indent-integration-salesforce/package.json create mode 100644 packages/beta/indent-integration-salesforce/src/index.ts create mode 100644 packages/beta/indent-integration-salesforce/src/salesforce-types.ts create mode 100644 packages/beta/indent-integration-salesforce/test/salesforce.test.ts create mode 100644 packages/beta/indent-integration-salesforce/tsconfig.build.json diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml index ad273552..07bea80e 100644 --- a/.github/workflows/build-examples.yml +++ b/.github/workflows/build-examples.yml @@ -27,6 +27,7 @@ jobs: - okta-auto-approval - github-issue - cloudflare + - salesforce indent-runtime: [aws-lambda] steps: - name: Checkout diff --git a/examples/aws-lambda-salesforce-webhook/.github/workflows/terraform.yml b/examples/aws-lambda-salesforce-webhook/.github/workflows/terraform.yml new file mode 100644 index 00000000..2860f3f0 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/.github/workflows/terraform.yml @@ -0,0 +1,81 @@ +name: deploy.indent-salesforce-webhook + +on: + push: + branches: + - main + pull_request: + +jobs: + terraform: + name: 'Terraform' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v1 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-session-token: ${{ secrets.AWS_SESSION_TOKEN }} # if you have/need it + aws-region: ${{ secrets.AWS_REGION }} + + - name: Terraform Format + id: fmt + run: terraform fmt -check -diff + + - name: Build Webhook (terraform-aws-salesforce-webhook) + run: cd terraform-aws-salesforce-webhook && npm run deploy:prepare && npm install && npm run build + + - name: Terraform Init + id: init + run: terraform init + + - name: Terraform Plan + id: plan + if: github.event_name == 'pull_request' + run: terraform plan -input=false -no-color + continue-on-error: true + env: + TF_VAR_indent_webhook_secret: ${{ secrets.SALESFORCE_WEBHOOK_SECRET }} + TF_VAR_indent_pull_webhook_secret: ${{ secrets.SALESFORCE_PULL_WEBHOOK_SECRET }} + TF_VAR_okta_domain: ${{ secrets.SALESFORCE_ACCOUNT}} + TF_VAR_okta_token: ${{ secrets.SALESFORCE_ACCESS_TOKEN }} + + - uses: actions/github-script@0.9.0 + if: github.event_name == 'pull_request' + env: + PLAN: "terraform\n${{ steps.plan.outputs.stdout }}" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\` + #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\` + #### Terraform Plan 📖\`${{ steps.plan.outcome }}\` +
Show Plan + \`\`\`${process.env.PLAN}\`\`\` +
+ *Actor: @${{ github.actor }}, Event: \`${{ github.event_name }}\`*`; + github.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: output + }) + - name: Terraform Plan Status + if: steps.plan.outcome == 'failure' + run: exit 1 + + - name: Terraform Apply + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + run: terraform apply -input=false -auto-approve + env: + TF_VAR_indent_webhook_secret: ${{ secrets.SALESFORCE_WEBHOOK_SECRET }} + TF_VAR_indent_pull_webhook_secret: ${{ secrets.SALESFORCE_PULL_WEBHOOK_SECRET }} + TF_VAR_salesforce_instance_url: ${{ secrets.SALESFORCE_INSTANCE_URL }} + TF_VAR_salesforce_access_token: ${{ secrets.SALESFORCE_ACCESS_TOKEN }} diff --git a/examples/aws-lambda-salesforce-webhook/.gitignore b/examples/aws-lambda-salesforce-webhook/.gitignore new file mode 100644 index 00000000..a768d9f3 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/.gitignore @@ -0,0 +1,12 @@ +data +dist +lib +.env +node_modules +*.tfstate +.terraform* +*.tfstate.* +terraform/config/*.tfvars +!terraform/config/example.tfvars +yarn.lock +package-lock.json \ No newline at end of file diff --git a/examples/aws-lambda-salesforce-webhook/README.md b/examples/aws-lambda-salesforce-webhook/README.md new file mode 100644 index 00000000..427caf1c --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/README.md @@ -0,0 +1 @@ +# Indent + Salesforce diff --git a/examples/aws-lambda-salesforce-webhook/main.tf b/examples/aws-lambda-salesforce-webhook/main.tf new file mode 100644 index 00000000..3b129e2a --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/main.tf @@ -0,0 +1,24 @@ +# terraform { +# backend "s3" { +# encrypt = true +# bucket = "" +# region = "us-west-2" +# key = "indent/terraform.tfstate" +# } +# } + +module "salesforce-pull-webhook" { + source = "./terraform-aws-salesforce-webhook/terraform" + + indent_webhook_secret = var.salesforce_pull_webhook_secret + salesforce_instance_url = var.salesforce_instance_url + salesforce_access_token = var.salesforce_access_token +} + +module "salesforce-change-webhook" { + source = "./terraform-aws-salesforce-webhook/terraform" + + indent_webhook_secret = var.salesforce_webhook_secret + salesforce_instance_url = var.salesforce_instance_url + salesforce_access_token = var.salesforce_access_token +} diff --git a/examples/aws-lambda-salesforce-webhook/outputs.tf b/examples/aws-lambda-salesforce-webhook/outputs.tf new file mode 100644 index 00000000..849350ae --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/outputs.tf @@ -0,0 +1,9 @@ +output "pull_api_base_url" { + value = module.salesforce-pull-webhook.api_base_url + description = "The URL of the deployed Lambda" +} + +output "api_base_url" { + value = module.salesforce-change-webhook.api_base_url + description = "The URL of the deployed Lambda" +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/.gitignore b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/.gitignore new file mode 100644 index 00000000..5f162567 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/.gitignore @@ -0,0 +1,10 @@ +data +dist +lib +.env +node_modules +*.tfstate +.terraform +*.tfstate.* +terraform/config/*.tfvars +!terraform/config/example.tfvars \ No newline at end of file diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/package.json b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/package.json new file mode 100644 index 00000000..57623927 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/package.json @@ -0,0 +1,40 @@ +{ + "name": "@indent/terraform-aws-salesforce-webhook", + "version": "0.0.0", + "description": "A Node.js starter for Terraform on AWS with Indent and Okta.", + "main": "index.js", + "private": true, + "scripts": { + "build": "tsc", + "clean:dist": "rm -rf dist", + "clean:modules": "rm -rf node_modules", + "clean:tf": "rm -rf terraform/.terraform && rm -rf terraform/terraform.tfstate*", + "clean:all": "npm run clean:dist; npm run clean:tf; npm run clean:modules", + "create:all": "npm run deploy:init; npm run deploy:prepare; npm run deploy:all", + "deploy:init": "cd terraform; terraform init", + "deploy:prepare": "npm install --production && ./scripts/build-layers.sh", + "deploy:all": "npm run build && npm run tf:apply -auto-approve", + "destroy:all": "npm run tf:destroy -auto-approve", + "tf:plan": "cd terraform && terraform plan -var-file ./config/terraform.tfvars", + "tf:apply": "cd terraform && terraform apply -compact-warnings -var-file ./config/terraform.tfvars", + "tf:destroy": "cd terraform && terraform destroy -auto-approve -var-file ./config/terraform.tfvars" + }, + "author": "Indent Inc ", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/indentapis/integrations.git" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.39", + "@types/node": "^13.9.8", + "@types/node-fetch": "^2.5.5", + "typescript": "^3.8.3" + }, + "dependencies": { + "@indent/runtime-aws-lambda": "canary", + "@indent/webhook": "latest", + "@indent/types": "latest", + "ts-node": "^8.5.4" + } +} \ No newline at end of file diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/readme.md b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/readme.md new file mode 100644 index 00000000..275303e0 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/readme.md @@ -0,0 +1 @@ +# Terraform AWS + Salesforce Webhook diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/scripts/build-layers.sh b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/scripts/build-layers.sh new file mode 100755 index 00000000..50f39fc1 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/scripts/build-layers.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -x +set -e + +ROOT_DIR="$(pwd)" + +OUTPUT_DIR="$(pwd)/dist" + +LAYER_DIR=$OUTPUT_DIR/layers/nodejs + +mkdir -p $LAYER_DIR + +cp -LR node_modules $LAYER_DIR + +cd $OUTPUT_DIR/layers + +zip -q -r layers.zip nodejs \ No newline at end of file diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/index.ts b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/index.ts new file mode 100644 index 00000000..43f58495 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/index.ts @@ -0,0 +1,6 @@ +import { getLambdaHandler } from '@indent/runtime-aws' +import { SalesforceIntegration } from './integration' + +export const handle = getLambdaHandler({ + integrations: [new SalesforceIntegration()], +}) diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/index.ts b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/index.ts new file mode 100644 index 00000000..d6005230 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/index.ts @@ -0,0 +1,207 @@ +import { + ApplyUpdateRequest, + BaseHttpIntegration, + BaseHttpIntegrationOpts, + FullIntegration, + HealthCheckResponse, + IntegrationInfoResponse, + PullUpdateRequest, + StatusCode, + WriteRequest, +} from '@indent/base-integration' +import { + ApplyUpdateResponse, + PullUpdateResponse, + Resource, +} from '@indent/types' +import jsforce from 'jsforce' +import { + SalesforceUserInfoResponse, + SalesforceUserRolesResponse, +} from './salesforce-types' + +const pkg = require('../package.json') +const SALESFORCE_INSTANCE_URL = process.env.SALESFORCE_INSTANCE_URL +const SALESFORCE_ACCESS_TOKEN = process.env.SALESFORCE_ACCESS_TOKEN + +export class SalesforceIntegration + extends BaseHttpIntegration + implements FullIntegration +{ + conn + constructor(opts?: BaseHttpIntegrationOpts) { + super(opts) + if (opts) { + this._name = opts.name + } + } + + HealthCheck(): HealthCheckResponse { + return { status: { code: 0 } } + } + + GetInfo(): IntegrationInfoResponse { + return { + name: ['indent-salesforce-webhook', this._name].filter(Boolean).join('#'), + capabilities: ['ApplyUpdate', 'PullUpdate'], + version: pkg.version, + } + } + + MatchApply(req: WriteRequest): boolean { + return req.events.some((e) => + e.resources?.some((r) => + r.kind?.toLowerCase().includes('salesforce.v1.user') + ) + ) + } + + async ConnectSalesforce(): Promise { + try { + this.conn = new jsforce.Connection({ + instanceUrl: SALESFORCE_INSTANCE_URL, + accessToken: SALESFORCE_ACCESS_TOKEN, + }) + } catch (err) { + console.error('Error connecting to Salesforce:', err) + throw err + } + } + + MatchPull(req) { + const lowercaseKinds = req.kinds.map((k) => k.toLowerCase()) + return ( + lowercaseKinds.includes('salesforce.v1.userrole') || + lowercaseKinds.includes('salesforce.v1.user') + ) + } + + async PullUpdate(_req: PullUpdateRequest): Promise { + if (!this.conn) { + this.ConnectSalesforce() + } + + let resources: Resource[] = [] + const timestamp = new Date().toISOString() + let res = { status: { code: StatusCode.UNKNOWN, message: '' } } + if (this.MatchPull(_req)) { + if (_req.kinds.includes('salesforce.v1.userRole')) { + try { + const userRole: SalesforceUserRolesResponse = await this.conn.query( + 'SELECT Id, Name FROM UserRole' + ) + console.log(`debug userRole: ${JSON.stringify(userRole, null, 1)}`) + + const kind = 'salesforce.v1.userRole' + resources = userRole.records.map((r) => ({ + id: r.Id, + displayName: r.Name, + kind, + labels: { + description: r.Name, + timestamp, + }, + })) as Resource[] + console.log( + `debug resources for UserRole: ${JSON.stringify( + resources, + null, + 1 + )}` + ) + } catch (err) { + res.status.code = StatusCode.INTERNAL + res.status.message = err.message + console.error(res.status.message) + } + } + + if (_req.kinds.includes('salesforce.v1.user')) { + try { + const userInfo: SalesforceUserInfoResponse = await this.conn.query( + 'SELECT Id, Name, IsActive, UserRole.Id, UserRole.Name, Profile.Id, Profile.Name, Profile.UserLicense.Id, Profile.UserLicense.Name FROM User' + ) + console.log(`debug userInfo: ${JSON.stringify(userInfo, null, 1)}`) + + const kind = 'salesforce.v1.user' + const userInfoResources: Resource[] = userInfo.records.map((r) => ({ + id: r.Id, + displayName: r.Name, + kind, + labels: { + description: r.Name, + timestamp, + 'salesforce/isActive': r.IsActive.toString(), + 'salesforce/role': r.UserRole?.Name ? r.UserRole?.Name : null, + 'salesforce/userLicense': r.Profile?.UserLicense?.Name + ? r.Profile?.UserLicense?.Name + : null, + }, + })) as Resource[] + console.log( + `debug resources for UserInfo: ${JSON.stringify( + userInfoResources, + null, + 1 + )}` + ) + + resources = resources.concat(userInfoResources) + } catch (err) { + res.status.code = StatusCode.UNAVAILABLE + res.status.message = err.message + console.error(res.status.message) + } + } + } + + return { + resources, + } + } + + async ApplyUpdate(_req: ApplyUpdateRequest): Promise { + if (!this.conn) { + this.ConnectSalesforce() + } + const auditEvent = _req.events.find((e) => /grant|revoke/.test(e.event)) + const { event, resources } = auditEvent + const granted = getResourceByKind(resources, 'salesforce.v1.user') + let res = { status: { code: StatusCode.UNKNOWN, message: '' } } + if (this.MatchApply(_req)) { + try { + if (event === 'access/grant') { + if (granted.kind === 'salesforce.v1.user') { + const isActive = event === 'access/grant' + await this.conn.sobject('User').update({ + Id: granted.id, + IsActive: isActive, + }) + } + res.status.code = StatusCode.OK + } else if (event === 'access/revoke') { + if (granted.kind === 'salesforce.v1.user') { + // For deactivating users when revoking access + await this.conn.sobject('User').update({ + Id: granted.id, + IsActive: false, + }) + } + + res.status.code = StatusCode.OK + } + } catch (err) { + res.status.code = StatusCode.UNAVAILABLE + res.status.message = err.message + console.error(res.status.message) + } + } + return res + } +} + +function getResourceByKind(resources, kind) { + return resources.find( + (r) => r.kind && r.kind.toLowerCase().includes(kind.toLowerCase()) + ) +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/salesforce-types.ts b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/salesforce-types.ts new file mode 100644 index 00000000..3ca6e2a9 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/salesforce-types.ts @@ -0,0 +1,67 @@ +export type SalesforceMembersResponse = { + totalSize: boolean + done: boolean + records: SalesforceMember[] +} + +export type SalesforceUserRolesResponse = { + totalSize: number + done: boolean + records: SalesforceRole[] +} + +export type SalesforceMember = { + attributes: { + type: string + url: string + } + Id: string + Name: string + UserRole: SalesforceRole | null +} + +export type SalesforceRole = { + attributes: { + type: string + url: string + } + Name: string + Id: string +} + +export type SalesforceUserInfoResponse = { + totalSize: number + done: boolean + records: SalesforceUserInfo[] +} + +export type SalesforceUserInfo = { + attributes: { + type: string + url: string + } + Id: string + Name: string + IsActive: boolean + Profile: SalesforceProfile | null + UserRole: SalesforceRole | null +} + +export type SalesforceProfile = { + attributes: { + type: string + url: string + } + Id: string + Name: string + UserLicense: SalesforceUserLicense +} + +export type SalesforceUserLicense = { + attributes: { + type: string + url: string + } + Id: string + Name: string +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/apiGateway.tf b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/apiGateway.tf new file mode 100644 index 00000000..14901b21 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/apiGateway.tf @@ -0,0 +1,64 @@ +resource "aws_api_gateway_rest_api" "api_gateway_rest_api" { + name = "api_gateway" + description = "Api Gateway for Lambda" +} + +resource "aws_api_gateway_resource" "api_gateway" { + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + parent_id = aws_api_gateway_rest_api.api_gateway_rest_api.root_resource_id + path_part = "{proxy+}" +} + +resource "aws_api_gateway_method" "api_gateway_method" { + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = aws_api_gateway_resource.api_gateway.id + http_method = "ANY" + authorization = "NONE" + + request_parameters = { + "method.request.header.x-indent-signature" = true + "method.request.header.x-indent-timestamp" = true + } +} + +resource "aws_api_gateway_integration" "api_gateway_integration" { + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = aws_api_gateway_method.api_gateway_method.resource_id + http_method = aws_api_gateway_method.api_gateway_method.http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.lambda.invoke_arn +} + +resource "aws_api_gateway_method" "api_gateway_root_method" { + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = aws_api_gateway_rest_api.api_gateway_rest_api.root_resource_id + http_method = "ANY" + authorization = "NONE" + + request_parameters = { + "method.request.header.x-indent-signature" = true + "method.request.header.x-indent-timestamp" = true + } +} + +resource "aws_api_gateway_integration" "api_gateway_root_integration" { + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + resource_id = aws_api_gateway_method.api_gateway_root_method.resource_id + http_method = aws_api_gateway_method.api_gateway_root_method.http_method + + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.lambda.invoke_arn +} + +resource "aws_api_gateway_deployment" "api_gateway_deployment" { + depends_on = [ + aws_api_gateway_integration.api_gateway_integration, + aws_api_gateway_integration.api_gateway_root_integration, + ] + + rest_api_id = aws_api_gateway_rest_api.api_gateway_rest_api.id + stage_name = "dev" +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/config/.gitkeep b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/config/example.tfvars b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/config/example.tfvars new file mode 100644 index 00000000..86a7296c --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/config/example.tfvars @@ -0,0 +1,8 @@ +# Indent Webhook Secret is used to verify messages from Indent +indent_webhook_secret = "" + +# Salesforce Instance Url - This is your Salesforce Instance URL +salesforce_instance_url = "" + +# Salesforce Token - Your Salesforce API Access token +salesforce_access_token = "" diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/iam.tf b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/iam.tf new file mode 100644 index 00000000..1bdf083b --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/iam.tf @@ -0,0 +1,52 @@ +data "aws_iam_policy_document" "lambda_assume_role_document" { + version = "2012-10-17" + + statement { + actions = ["sts:AssumeRole"] + + principals { + type = "Service" + identifiers = ["lambda.amazonaws.com"] + } + + effect = "Allow" + } +} + +data "aws_caller_identity" "current" {} + +data "aws_iam_policy_document" "lambda_document" { + version = "2012-10-17" + + statement { + effect = "Allow" + + actions = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents", + "cloudwatch:PutMetricData", + ] + + resources = ["arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/${local.name}:*"] + } +} + +resource "aws_iam_policy" "lambda_policy" { + policy = data.aws_iam_policy_document.lambda_document.json +} + +resource "aws_iam_role" "lambda_role" { + name = "${local.name}-role" + assume_role_policy = data.aws_iam_policy_document.lambda_assume_role_document.json + + tags = local.tags +} + +resource "aws_iam_policy_attachment" "lambda_attachment" { + name = "${local.name}-attachment" + + roles = [aws_iam_role.lambda_role.name] + + policy_arn = aws_iam_policy.lambda_policy.arn +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/lambda.tf b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/lambda.tf new file mode 100644 index 00000000..b331bd7f --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/lambda.tf @@ -0,0 +1,44 @@ +data "archive_file" "function_archive" { + type = "zip" + source_dir = "${path.module}/../lib" + output_path = "${path.module}/../dist/function.zip" +} + +resource "aws_lambda_layer_version" "deps" { + compatible_runtimes = ["nodejs14.x"] + layer_name = "${local.name}-dependency_layer" + filename = "${path.module}/../dist/layers/layers.zip" + source_code_hash = filesha256("${path.module}/../dist/layers/layers.zip") +} + +resource "aws_lambda_function" "lambda" { + function_name = local.name + role = aws_iam_role.lambda_role.arn + filename = data.archive_file.function_archive.output_path + source_code_hash = data.archive_file.function_archive.output_base64sha256 + memory_size = local.lambda_memory + handler = "index.handle" + runtime = "nodejs14.x" + timeout = "30" + + layers = [aws_lambda_layer_version.deps.arn] + + environment { + variables = { + "INDENT_WEBHOOK_SECRET" = var.indent_webhook_secret + "SALESFORCE_INSTANCE_URL" = var.salesforce_instance_url + "SALESFORCE_ACCESS_TOKEN" = var.salesforce_access_token + } + } +} + +resource "aws_lambda_permission" "lambda" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.lambda.function_name + principal = "apigateway.amazonaws.com" + + # The "/*/*" portion grants access from any method on any resource + # within the API Gateway REST API. + source_arn = "${aws_api_gateway_rest_api.api_gateway_rest_api.execution_arn}/*/*" +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/locals.tf b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/locals.tf new file mode 100644 index 00000000..ecf430b3 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/locals.tf @@ -0,0 +1,16 @@ +locals { + name = "indent-salesforce-webhook-${random_string.suffix.result}" + lambda_memory = 128 + + tags = { + Name = "Indent + Salesforce on AWS via Terraform" + GitRepo = "https://github.com/indentapis/integrations" + ProvidedBy = "Indent" + } +} + +resource "random_string" "suffix" { + length = 4 + upper = false + special = false +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/outputs.tf b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/outputs.tf new file mode 100644 index 00000000..74823a54 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/outputs.tf @@ -0,0 +1,4 @@ +output "api_base_url" { + value = aws_api_gateway_deployment.api_gateway_deployment.invoke_url + description = "The URL of the deployed Lambda" +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/provider.tf b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/provider.tf new file mode 100644 index 00000000..9b799b02 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/provider.tf @@ -0,0 +1,17 @@ +provider "aws" { + profile = var.aws_profile + region = var.aws_region + max_retries = 1 +} + +terraform { + required_providers { + random = { + source = "hashicorp/random" + } + + aws = { + version = "~> 3.0" + } + } +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/variables.tf b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/variables.tf new file mode 100644 index 00000000..ceabd41c --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/terraform/variables.tf @@ -0,0 +1,26 @@ +variable "aws_region" { + type = string + default = "us-west-2" +} + +variable "aws_profile" { + type = string + default = "default" +} + +variable "indent_webhook_secret" { + type = string + sensitive = true +} + +variable "salesforce_instance_url" { + type = string + default = "" + sensitive = true +} + +variable "salesforce_access_token" { + type = string + default = "" + sensitive = true +} diff --git a/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/tsconfig.json b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/tsconfig.json new file mode 100644 index 00000000..1bfde1b4 --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/tsconfig.json @@ -0,0 +1,16 @@ +{ + "exclude": ["node_modules"], + "compilerOptions": { + "module": "commonjs", + "esModuleInterop": true, + "lib": ["esnext", "dom"], + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "sourceMap": true, + "target": "esnext", + "outDir": "lib" + }, + "include": ["./src/**/*"] +} diff --git a/examples/aws-lambda-salesforce-webhook/variables.tf b/examples/aws-lambda-salesforce-webhook/variables.tf new file mode 100644 index 00000000..9dd7f70c --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/variables.tf @@ -0,0 +1,33 @@ +variable "aws_region" { + type = string + default = "us-west-2" +} + +variable "aws_profile" { + type = string + default = "default" +} + +variable "salesforce_webhook_secret" { + type = string + default = "" + sensitive = true +} + +variable "salesforce_pull_webhook_secret" { + type = string + default = "" + sensitive = true +} + +variable "salesforce_instance_url" { + type = string + default = "" + sensitive = true +} + +variable "salesforce_access_token" { + type = string + default = "" + sensitive = true +} \ No newline at end of file diff --git a/packages/beta/indent-integration-salesforce/CHANGELOG.md b/packages/beta/indent-integration-salesforce/CHANGELOG.md new file mode 100644 index 00000000..420e6f23 --- /dev/null +++ b/packages/beta/indent-integration-salesforce/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log diff --git a/packages/beta/indent-integration-salesforce/package.json b/packages/beta/indent-integration-salesforce/package.json new file mode 100644 index 00000000..de7b596c --- /dev/null +++ b/packages/beta/indent-integration-salesforce/package.json @@ -0,0 +1,23 @@ +{ + "name": "@indent/integration-salesforce", + "main": "lib/index", + "types": "lib/index", + "version": "0.0.1-canary.01", + "scripts": { + "dev": "tsc -p tsconfig.build.json -w", + "test": "SALESFORCE_INSTANCE_URL=https://random.com SALESFORCE_ACCESS_TOKEN=example_token jest --config ../../../jest.config.js salesforce.*", + "build": "yarn run clean && yarn run compile", + "clean": "rimraf -rf ./lib", + "compile": "tsc -p tsconfig.build.json", + "prepublishOnly": "yarn run build" + }, + "dependencies": { + "@indent/base-integration": "^0.0.1-canary.31", + "jsforce": "^1.11.1" + }, + "publishConfig": { + "access": "public" + }, + "license": "Apache-2.0", + "gitHead": "HEAD~1" +} \ No newline at end of file diff --git a/packages/beta/indent-integration-salesforce/src/index.ts b/packages/beta/indent-integration-salesforce/src/index.ts new file mode 100644 index 00000000..d6005230 --- /dev/null +++ b/packages/beta/indent-integration-salesforce/src/index.ts @@ -0,0 +1,207 @@ +import { + ApplyUpdateRequest, + BaseHttpIntegration, + BaseHttpIntegrationOpts, + FullIntegration, + HealthCheckResponse, + IntegrationInfoResponse, + PullUpdateRequest, + StatusCode, + WriteRequest, +} from '@indent/base-integration' +import { + ApplyUpdateResponse, + PullUpdateResponse, + Resource, +} from '@indent/types' +import jsforce from 'jsforce' +import { + SalesforceUserInfoResponse, + SalesforceUserRolesResponse, +} from './salesforce-types' + +const pkg = require('../package.json') +const SALESFORCE_INSTANCE_URL = process.env.SALESFORCE_INSTANCE_URL +const SALESFORCE_ACCESS_TOKEN = process.env.SALESFORCE_ACCESS_TOKEN + +export class SalesforceIntegration + extends BaseHttpIntegration + implements FullIntegration +{ + conn + constructor(opts?: BaseHttpIntegrationOpts) { + super(opts) + if (opts) { + this._name = opts.name + } + } + + HealthCheck(): HealthCheckResponse { + return { status: { code: 0 } } + } + + GetInfo(): IntegrationInfoResponse { + return { + name: ['indent-salesforce-webhook', this._name].filter(Boolean).join('#'), + capabilities: ['ApplyUpdate', 'PullUpdate'], + version: pkg.version, + } + } + + MatchApply(req: WriteRequest): boolean { + return req.events.some((e) => + e.resources?.some((r) => + r.kind?.toLowerCase().includes('salesforce.v1.user') + ) + ) + } + + async ConnectSalesforce(): Promise { + try { + this.conn = new jsforce.Connection({ + instanceUrl: SALESFORCE_INSTANCE_URL, + accessToken: SALESFORCE_ACCESS_TOKEN, + }) + } catch (err) { + console.error('Error connecting to Salesforce:', err) + throw err + } + } + + MatchPull(req) { + const lowercaseKinds = req.kinds.map((k) => k.toLowerCase()) + return ( + lowercaseKinds.includes('salesforce.v1.userrole') || + lowercaseKinds.includes('salesforce.v1.user') + ) + } + + async PullUpdate(_req: PullUpdateRequest): Promise { + if (!this.conn) { + this.ConnectSalesforce() + } + + let resources: Resource[] = [] + const timestamp = new Date().toISOString() + let res = { status: { code: StatusCode.UNKNOWN, message: '' } } + if (this.MatchPull(_req)) { + if (_req.kinds.includes('salesforce.v1.userRole')) { + try { + const userRole: SalesforceUserRolesResponse = await this.conn.query( + 'SELECT Id, Name FROM UserRole' + ) + console.log(`debug userRole: ${JSON.stringify(userRole, null, 1)}`) + + const kind = 'salesforce.v1.userRole' + resources = userRole.records.map((r) => ({ + id: r.Id, + displayName: r.Name, + kind, + labels: { + description: r.Name, + timestamp, + }, + })) as Resource[] + console.log( + `debug resources for UserRole: ${JSON.stringify( + resources, + null, + 1 + )}` + ) + } catch (err) { + res.status.code = StatusCode.INTERNAL + res.status.message = err.message + console.error(res.status.message) + } + } + + if (_req.kinds.includes('salesforce.v1.user')) { + try { + const userInfo: SalesforceUserInfoResponse = await this.conn.query( + 'SELECT Id, Name, IsActive, UserRole.Id, UserRole.Name, Profile.Id, Profile.Name, Profile.UserLicense.Id, Profile.UserLicense.Name FROM User' + ) + console.log(`debug userInfo: ${JSON.stringify(userInfo, null, 1)}`) + + const kind = 'salesforce.v1.user' + const userInfoResources: Resource[] = userInfo.records.map((r) => ({ + id: r.Id, + displayName: r.Name, + kind, + labels: { + description: r.Name, + timestamp, + 'salesforce/isActive': r.IsActive.toString(), + 'salesforce/role': r.UserRole?.Name ? r.UserRole?.Name : null, + 'salesforce/userLicense': r.Profile?.UserLicense?.Name + ? r.Profile?.UserLicense?.Name + : null, + }, + })) as Resource[] + console.log( + `debug resources for UserInfo: ${JSON.stringify( + userInfoResources, + null, + 1 + )}` + ) + + resources = resources.concat(userInfoResources) + } catch (err) { + res.status.code = StatusCode.UNAVAILABLE + res.status.message = err.message + console.error(res.status.message) + } + } + } + + return { + resources, + } + } + + async ApplyUpdate(_req: ApplyUpdateRequest): Promise { + if (!this.conn) { + this.ConnectSalesforce() + } + const auditEvent = _req.events.find((e) => /grant|revoke/.test(e.event)) + const { event, resources } = auditEvent + const granted = getResourceByKind(resources, 'salesforce.v1.user') + let res = { status: { code: StatusCode.UNKNOWN, message: '' } } + if (this.MatchApply(_req)) { + try { + if (event === 'access/grant') { + if (granted.kind === 'salesforce.v1.user') { + const isActive = event === 'access/grant' + await this.conn.sobject('User').update({ + Id: granted.id, + IsActive: isActive, + }) + } + res.status.code = StatusCode.OK + } else if (event === 'access/revoke') { + if (granted.kind === 'salesforce.v1.user') { + // For deactivating users when revoking access + await this.conn.sobject('User').update({ + Id: granted.id, + IsActive: false, + }) + } + + res.status.code = StatusCode.OK + } + } catch (err) { + res.status.code = StatusCode.UNAVAILABLE + res.status.message = err.message + console.error(res.status.message) + } + } + return res + } +} + +function getResourceByKind(resources, kind) { + return resources.find( + (r) => r.kind && r.kind.toLowerCase().includes(kind.toLowerCase()) + ) +} diff --git a/packages/beta/indent-integration-salesforce/src/salesforce-types.ts b/packages/beta/indent-integration-salesforce/src/salesforce-types.ts new file mode 100644 index 00000000..3ca6e2a9 --- /dev/null +++ b/packages/beta/indent-integration-salesforce/src/salesforce-types.ts @@ -0,0 +1,67 @@ +export type SalesforceMembersResponse = { + totalSize: boolean + done: boolean + records: SalesforceMember[] +} + +export type SalesforceUserRolesResponse = { + totalSize: number + done: boolean + records: SalesforceRole[] +} + +export type SalesforceMember = { + attributes: { + type: string + url: string + } + Id: string + Name: string + UserRole: SalesforceRole | null +} + +export type SalesforceRole = { + attributes: { + type: string + url: string + } + Name: string + Id: string +} + +export type SalesforceUserInfoResponse = { + totalSize: number + done: boolean + records: SalesforceUserInfo[] +} + +export type SalesforceUserInfo = { + attributes: { + type: string + url: string + } + Id: string + Name: string + IsActive: boolean + Profile: SalesforceProfile | null + UserRole: SalesforceRole | null +} + +export type SalesforceProfile = { + attributes: { + type: string + url: string + } + Id: string + Name: string + UserLicense: SalesforceUserLicense +} + +export type SalesforceUserLicense = { + attributes: { + type: string + url: string + } + Id: string + Name: string +} diff --git a/packages/beta/indent-integration-salesforce/test/salesforce.test.ts b/packages/beta/indent-integration-salesforce/test/salesforce.test.ts new file mode 100644 index 00000000..e24a228f --- /dev/null +++ b/packages/beta/indent-integration-salesforce/test/salesforce.test.ts @@ -0,0 +1,188 @@ +import { HealthCheckResponse } from '@indent/base-integration' +import { + ApplyUpdateResponse, + PullUpdateResponse, + Resource, +} from '@indent/types' +import jsforce from 'jsforce' +import { SalesforceIntegration } from '../src' +import { + SalesforceUserInfoResponse, + SalesforceUserRolesResponse, +} from '../src/salesforce-types' + +jest.mock('jsforce', () => { + const originalJsForce = jest.requireActual('jsforce') + return { + ...originalJsForce, + Connection: jest.fn().mockImplementation(() => ({ + query: jest.fn(), + sobject: (_objName) => ({ + update: jest.fn(), + }), + })), + } +}) + +describe('SalesforceIntegration', () => { + let salesforceIntegration: SalesforceIntegration + let mockConnection: any + + beforeEach(() => { + mockConnection = new jsforce.Connection() + salesforceIntegration = new SalesforceIntegration() + salesforceIntegration.conn = mockConnection + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('HealthCheck', () => { + it('should return a health check response', () => { + const response: HealthCheckResponse = salesforceIntegration.HealthCheck() + expect(response.status.code).toBe(0) + }) + }) + + describe('GetInfo', () => { + it('should return integration information', () => { + const response = salesforceIntegration.GetInfo() + expect(response.name).toEqual(expect.any(String)) + expect(response.capabilities).toEqual(['ApplyUpdate', 'PullUpdate']) + expect(response.version).toEqual(expect.any(String)) + }) + }) + + describe('PullUpdate', () => { + it('should return resources for salesforce.v1.userrole and salesforce.v1.user', async () => { + const pullUpdateRequest = { + kinds: ['salesforce.v1.userRole', 'salesforce.v1.user'], + } + + const expectedResources: Resource[] = [ + // Mocked resources for 'salesforce.v1.userrole' + { + id: 'roleId1', + displayName: 'Role 1', + kind: 'salesforce.v1.userRole', + labels: { + description: 'Role 1', + timestamp: expect.any(String), + }, + }, + // Mocked resources for 'salesforce.v1.user' + { + id: 'userId1', + displayName: 'User 1', + kind: 'salesforce.v1.user', + labels: { + description: 'User 1', + timestamp: expect.any(String), + 'salesforce/isActive': 'true', + 'salesforce/role': null, + 'salesforce/userLicense': null, + }, + }, + ] + + const userRolesResponse: SalesforceUserRolesResponse = { + totalSize: 1, + done: true, + records: [ + { + attributes: { + type: 'string', + url: 'string', + }, + Id: 'roleId1', + Name: 'Role 1', + }, + ], + } + + const userInfoResponse: SalesforceUserInfoResponse = { + totalSize: 1, + done: true, + records: [ + { + attributes: { + type: 'string', + url: 'string', + }, + Id: 'userId1', + Name: 'User 1', + IsActive: true, + UserRole: null, + Profile: null, + }, + ], + } + + salesforceIntegration.conn.query + .mockResolvedValueOnce(userRolesResponse) + .mockResolvedValueOnce(userInfoResponse) + + const response: PullUpdateResponse = + await salesforceIntegration.PullUpdate(pullUpdateRequest) + + expect(response.resources).toStrictEqual(expectedResources) + }) + }) + + describe('ApplyUpdate', () => { + it('should grant access for salesforce.v1.userrole and activate/deactivate users for salesforce.v1.user', async () => { + const applyUpdateRequest = { + events: [ + { + event: 'access/grant', + resources: [ + { + id: 'userId1', + kind: 'salesforce.v1.user', + }, + ], + }, + { + event: 'access/revoke', + resources: [ + { + id: 'userId2', + kind: 'salesforce.v1.user', + }, + ], + }, + ], + } + + const expectedApplyUpdateResponse: ApplyUpdateResponse = { + status: { + code: 0, + message: '', + }, + } + + const userRolesResponse: SalesforceUserRolesResponse = { + totalSize: 1, + done: true, + records: [ + { + attributes: { + type: 'string', + url: 'string', + }, + Id: 'roleId1', + Name: 'Role 1', + }, + ], + } + + mockConnection.query.mockResolvedValueOnce(userRolesResponse) + + const response: ApplyUpdateResponse = + await salesforceIntegration.ApplyUpdate(applyUpdateRequest) + + expect(response).toStrictEqual(expectedApplyUpdateResponse) + }) + }) +}) diff --git a/packages/beta/indent-integration-salesforce/tsconfig.build.json b/packages/beta/indent-integration-salesforce/tsconfig.build.json new file mode 100644 index 00000000..544bf9fe --- /dev/null +++ b/packages/beta/indent-integration-salesforce/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.build.json", + "include": ["src"], + "exclude": ["test"], + "compilerOptions": { + "outDir": "./lib" + } +} diff --git a/templates/scripts/steps/catalog.ts b/templates/scripts/steps/catalog.ts index f0f4e55d..e8d0b206 100644 --- a/templates/scripts/steps/catalog.ts +++ b/templates/scripts/steps/catalog.ts @@ -218,6 +218,23 @@ export const catalog: CatalogItem[] = [ 'this link', }, }, + { + name: 'salesforce', + displayName: 'Salesforce', + integrations: ['SalesforceIntegration'], + runtimes: ['AWS Lambda'], + environmentVariables: [ + 'SALESFORCE_INSTANCE_URL', + 'SALESFORCE_ACCESS_TOKEN', + ], + capabilities: ['PullUpdate', 'ApplyUpdate'], + links: { repoSource: 'packages/beta/indent-integration-salesforce' }, + readme: { + connection: [], + docsLink: + 'this link', + }, + }, { name: 'github-issue', displayName: 'GitHub Issues',