From 03d0c34f1e0b21ad7978941859bc6f1f39b646ef 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/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 | 176 ++++++++++++++++++ .../src/integration/salesforce-types.ts | 26 +++ .../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 | 176 ++++++++++++++++++ .../src/salesforce-types.ts | 26 +++ .../test/salesforce.test.ts | 36 ++++ .../tsconfig.build.json | 8 + 29 files changed, 953 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/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..cd45f09e --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/index.ts @@ -0,0 +1,176 @@ +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 { SalesforceUsers } 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.filter((e) => + Boolean( + e.resources?.filter((r) => + r.kind?.toLowerCase().includes('salesforce.v1.userlicense') + ).length + ) + ).length > 0 + ) + } + + async ConnectSalesforce(): Promise { + this.conn = new jsforce.Connection({ + instanceUrl: SALESFORCE_INSTANCE_URL, + accessToken: SALESFORCE_ACCESS_TOKEN, + }) + } + + MatchPull(req) { + return req.kinds + .map((k) => k.toLowerCase()) + .includes('salesforce.v1.userlicense') + } + + async PullUpdate(_req: PullUpdateRequest): Promise { + if (!this.conn) { + this.ConnectSalesforce() + } + const userLicenses = await this.conn.query( + 'SELECT Id, Name, CreatedDate, Status FROM UserLicense' + ) + console.log(`debug userLicenses: ${JSON.stringify(userLicenses, null, 1)}`) + + const kind = 'salesforce.v1.userLicense' + + const userIds = userLicenses.records.map((license) => license.Id) + const users: SalesforceUsers = await this.conn.query( + `SELECT Id, Name, UserRole.Id, UserRole.Name, Profile.Id, Profile.Name,Profile.UserLicense.Id, Profile.UserLicense.Name FROM User WHERE Profile.UserLicense.Id IN ('${userIds.join( + "','" + )}')` + ) + console.log(`debug users: ${JSON.stringify(users, null, 1)}`) + + const userMap = {} + users.records.forEach((user) => { + const licenseId = user.Profile?.UserLicense?.Id + if (licenseId) { + userMap[licenseId] = user.Profile.Id // Mapping UserLicenseId to Profile record + } + }) + const timestamp = new Date().toISOString() + const resources: Resource[] = userLicenses.records.map((license) => ({ + id: license.Id, + displayName: license.Name, + kind, + labels: { + description: license.Name, + timestamp, + 'salesforce/licenseStatus': license.Status, + 'salesforce/profileId': userMap[license.Id] || 'N/A', + }, + })) as Resource[] + console.log(`debug resources: ${JSON.stringify(resources, null, 1)}`) + + 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 grantee = getResourceByKind(resources, 'user') + const granted = getResourceByKind(resources, 'salesforce.v1.userLicense') + const grantedUserProfile = granted.labels['salesforce/profileId'] + + const allUsers: SalesforceUsers = await this.conn.query( + `SELECT Id, Name, UserRole.Id, UserRole.Name, Profile.Id, Profile.Name,Profile.UserLicense.Id, Profile.UserLicense.Name FROM User` + ) + + const user = allUsers.records.find((u) => u.Id === grantee.id) + let res = { status: { code: StatusCode.UNKNOWN, message: '' } } + + try { + if (event === 'access/grant') { + const existingLicense = user.Profile.UserLicense.Id === granted.id + if (!existingLicense) { + const updateData = { + Id: grantee.id, + ProfileId: grantedUserProfile, + } + + if (grantedUserProfile) { + updateData.ProfileId = grantedUserProfile + } + + await this.conn.sobject('User').update(updateData) + } + + res.status.code = StatusCode.OK + } else if (event === 'access/revoke') { + const profilesWithoutLicense = allUsers.records.filter( + (user) => !user.Profile.UserLicense.Id + ) + await this.conn.sobject('User').update({ + Id: grantee.id, + ProfileId: profilesWithoutLicense?.[0].Profile.Id || null, + }) + res.status.code = StatusCode.OK + } + } catch (err) { + res.status.code = StatusCode.INTERNAL + res.status.message = err.message + console.error('failed to update role and license') + 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..a7ee5abe --- /dev/null +++ b/examples/aws-lambda-salesforce-webhook/terraform-aws-salesforce-webhook/src/integration/salesforce-types.ts @@ -0,0 +1,26 @@ +interface Attributes { + type: string + url: string +} + +interface UserRecord { + attributes: Attributes + Id: string + Name: string + Profile: { + attributes: Attributes + Id: string + Name: string + UserLicense: { + attributes: Attributes + Id: string + Name: string + } + } | null +} + +export interface SalesforceUsers { + totalSize: number + done: boolean + records: UserRecord[] +} 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..cd45f09e --- /dev/null +++ b/packages/beta/indent-integration-salesforce/src/index.ts @@ -0,0 +1,176 @@ +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 { SalesforceUsers } 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.filter((e) => + Boolean( + e.resources?.filter((r) => + r.kind?.toLowerCase().includes('salesforce.v1.userlicense') + ).length + ) + ).length > 0 + ) + } + + async ConnectSalesforce(): Promise { + this.conn = new jsforce.Connection({ + instanceUrl: SALESFORCE_INSTANCE_URL, + accessToken: SALESFORCE_ACCESS_TOKEN, + }) + } + + MatchPull(req) { + return req.kinds + .map((k) => k.toLowerCase()) + .includes('salesforce.v1.userlicense') + } + + async PullUpdate(_req: PullUpdateRequest): Promise { + if (!this.conn) { + this.ConnectSalesforce() + } + const userLicenses = await this.conn.query( + 'SELECT Id, Name, CreatedDate, Status FROM UserLicense' + ) + console.log(`debug userLicenses: ${JSON.stringify(userLicenses, null, 1)}`) + + const kind = 'salesforce.v1.userLicense' + + const userIds = userLicenses.records.map((license) => license.Id) + const users: SalesforceUsers = await this.conn.query( + `SELECT Id, Name, UserRole.Id, UserRole.Name, Profile.Id, Profile.Name,Profile.UserLicense.Id, Profile.UserLicense.Name FROM User WHERE Profile.UserLicense.Id IN ('${userIds.join( + "','" + )}')` + ) + console.log(`debug users: ${JSON.stringify(users, null, 1)}`) + + const userMap = {} + users.records.forEach((user) => { + const licenseId = user.Profile?.UserLicense?.Id + if (licenseId) { + userMap[licenseId] = user.Profile.Id // Mapping UserLicenseId to Profile record + } + }) + const timestamp = new Date().toISOString() + const resources: Resource[] = userLicenses.records.map((license) => ({ + id: license.Id, + displayName: license.Name, + kind, + labels: { + description: license.Name, + timestamp, + 'salesforce/licenseStatus': license.Status, + 'salesforce/profileId': userMap[license.Id] || 'N/A', + }, + })) as Resource[] + console.log(`debug resources: ${JSON.stringify(resources, null, 1)}`) + + 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 grantee = getResourceByKind(resources, 'user') + const granted = getResourceByKind(resources, 'salesforce.v1.userLicense') + const grantedUserProfile = granted.labels['salesforce/profileId'] + + const allUsers: SalesforceUsers = await this.conn.query( + `SELECT Id, Name, UserRole.Id, UserRole.Name, Profile.Id, Profile.Name,Profile.UserLicense.Id, Profile.UserLicense.Name FROM User` + ) + + const user = allUsers.records.find((u) => u.Id === grantee.id) + let res = { status: { code: StatusCode.UNKNOWN, message: '' } } + + try { + if (event === 'access/grant') { + const existingLicense = user.Profile.UserLicense.Id === granted.id + if (!existingLicense) { + const updateData = { + Id: grantee.id, + ProfileId: grantedUserProfile, + } + + if (grantedUserProfile) { + updateData.ProfileId = grantedUserProfile + } + + await this.conn.sobject('User').update(updateData) + } + + res.status.code = StatusCode.OK + } else if (event === 'access/revoke') { + const profilesWithoutLicense = allUsers.records.filter( + (user) => !user.Profile.UserLicense.Id + ) + await this.conn.sobject('User').update({ + Id: grantee.id, + ProfileId: profilesWithoutLicense?.[0].Profile.Id || null, + }) + res.status.code = StatusCode.OK + } + } catch (err) { + res.status.code = StatusCode.INTERNAL + res.status.message = err.message + console.error('failed to update role and license') + 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..a7ee5abe --- /dev/null +++ b/packages/beta/indent-integration-salesforce/src/salesforce-types.ts @@ -0,0 +1,26 @@ +interface Attributes { + type: string + url: string +} + +interface UserRecord { + attributes: Attributes + Id: string + Name: string + Profile: { + attributes: Attributes + Id: string + Name: string + UserLicense: { + attributes: Attributes + Id: string + Name: string + } + } | null +} + +export interface SalesforceUsers { + totalSize: number + done: boolean + records: UserRecord[] +} 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..41444de8 --- /dev/null +++ b/packages/beta/indent-integration-salesforce/test/salesforce.test.ts @@ -0,0 +1,36 @@ +import { HealthCheckResponse } from '@indent/base-integration' +import jsforce from 'jsforce' +import { SalesforceIntegration } from '../src' + +jest.mock('jsforce') + +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)) + }) + }) +}) 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" + } +}