Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/finoptic-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Finoptic fork: build the OpenOps app and worker images (with the Finoptic
# blocks in dist/packages/blocks) and push them to ECR.
#
# - Pull requests: build the blocks and run their tests on a GitHub-hosted
# runner. No AWS access, nothing is pushed.
# - Tag push finoptic-<version>: build and push on the CodeBuild runner
# project finoptic-openops-release (finoptic-infra, codebuild/openops-release.tf).
# Only that job holds the role that can write to ECR, so code from a
# pull request never runs with it.
#
# amd64 only. The EKS nodes that run OpenOps are amd64; building on the arm64
# Mac laptops produces images that die with "exec format error" there, so the
# platform is pinned explicitly below.
name: Finoptic images

on:
pull_request:
paths:
- 'packages/blocks/finoptic-*/**'
- '.github/workflows/finoptic-images.yml'
push:
tags: ['finoptic-*']
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}

jobs:
test-blocks:
name: Test Finoptic blocks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
- run: npm ci --ignore-scripts --no-audit --no-fund
- run: npx nx run-many --target=test,lint,build --projects=blocks-finoptic-aws,blocks-finoptic-approval

release-images:
name: Build and push images (amd64)
if: startsWith(github.ref, 'refs/tags/finoptic-')
needs: test-blocks
# Large compute, privileged for Docker; 120 min like finoptic-app-release.
runs-on: codebuild-finoptic-openops-release-${{ github.run_id }}-${{ github.run_attempt }}
env:
DOCKER_DEFAULT_PLATFORM: linux/amd64
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
- name: Build dist (all projects, includes the Finoptic blocks)
run: |
npm ci --no-audit --no-fund
npm run prepare
npx nx run-many --target=build
- name: Check the Finoptic blocks are in dist
run: |
test -f dist/packages/blocks/finoptic-aws/src/index.js
test -f dist/packages/blocks/finoptic-approval/src/index.js
- name: Log in to ECR
run: |
REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-1}}"
ACCOUNT="$(aws sts get-caller-identity --query Account --output text)"
echo "REGISTRY=${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com" >> "$GITHUB_ENV"
aws ecr get-login-password --region "$REGION" \
| docker login --username AWS --password-stdin "${ACCOUNT}.dkr.ecr.${REGION}.amazonaws.com"
- name: Build and push
run: |
VERSION="${GITHUB_REF_NAME#finoptic-}"
docker buildx create --use >/dev/null
for pair in "Dockerfile:openops-app" "worker.Dockerfile:openops-worker"; do
FILE="${pair%%:*}"; REPO="finoptic/${pair##*:}"
# Repositories are IMMUTABLE: a version is pushed once.
if aws ecr describe-images --repository-name "$REPO" --image-ids imageTag="$VERSION" >/dev/null 2>&1; then
echo "::error::$REPO:$VERSION already exists"; exit 1
fi
docker buildx build --platform linux/amd64 --provenance=false \
--build-arg VERSION="$VERSION" \
-f "$FILE" -t "$REGISTRY/$REPO:$VERSION" --push .
done
42 changes: 42 additions & 0 deletions packages/blocks/finoptic-approval/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# blocks-finoptic-approval

Finoptic supervisor approval gate. One action, `supervisor_approval`, which must be the first step of every Finoptic flow (Finoptic enforces this when it creates a flow, and its credential broker refuses to issue credentials for an execution that has no approval).

## How it pauses

Same mechanism as the core "Wait for Approval" block (`packages/blocks/approval/src/lib/actions/wait-for-approval.ts`):

- `ctx.run.pause({ pauseMetadata: { response: {} } })` on `ExecutionType.BEGIN` (hook in `packages/engine/src/lib/handler/block-executor.ts`, `createPauseHook`). The run goes to PAUSED.
- The run is resumed through `ANY /v1/flow-runs/:id/requests/:executionCorrelationId` (`packages/server/api/src/app/flows/flow-run/flow-run-controller.ts`, public route). The step runs again with `ExecutionType.RESUME` and `ctx.resumePayload.queryParams`.

## Contract with Finoptic

Env on the worker: `FINOPTIC_BACKEND_URL`, `FINOPTIC_BROKER_SECRET`. Header `X-Broker-Secret: $FINOPTIC_BROKER_SECRET` on every call. The JSON shapes are in the fixture `packages/blocks/finoptic-aws/test/fixtures/contract.json`, which the Finoptic backend tests too (Go, `internal/automations/broker/testdata/contract.json`). Keep the two files identical.

On BEGIN the block calls:

```
POST $FINOPTIC_BACKEND_URL/internal/automations/approvals
{ "executionId", "flowId", "flowRunId", "summary", "approveUrl", "rejectUrl" }
201 { "executionId", "status": "pending" }
```

Any 2xx = accepted, and the run pauses. Any other answer fails the step and nothing is paused.

`approveUrl` and `rejectUrl` come from `ctx.generateResumeUrl`:

```
<internal api>/v1/flow-runs/{flowRunId}/requests/{pauseId}?action=approve&path=...
<internal api>/v1/flow-runs/{flowRunId}/requests/{pauseId}?action=reject&path=...
```

They are sent only to Finoptic and are not part of the step output. The pause id is the key to resume the run, so Finoptic stores them encrypted, never logs them, and calls them only from its own org-admin decision endpoint (`POST .../automations/approvals/:executionId/decision`). Nothing else resumes a run.

On RESUME the query `action` is not trusted on its own. The block calls:

```
GET $FINOPTIC_BACKEND_URL/internal/automations/approvals/{executionId}
200 { "executionId", "status": "pending" | "approved" | "rejected" }
```

and continues with `{ approved: true }` only when `action=approve` AND `status` is `approved`. Anything else (`reject`, missing, unknown action, or a status other than `approved`) calls `ctx.run.stop`. If Finoptic cannot be reached or does not know the execution, the step fails: the run never goes on without a confirmed approval. So a direct hit on a resume URL cannot get a flow past this step.
11 changes: 11 additions & 0 deletions packages/blocks/finoptic-approval/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default {
displayName: 'blocks-finoptic-approval',
preset: '../../../jest.preset.js',
setupFiles: ['../../../jest.env.js'],
testEnvironment: 'node',
transform: {
'^.+\\.ts$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/packages/blocks/finoptic-approval',
};
4 changes: 4 additions & 0 deletions packages/blocks/finoptic-approval/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "@finoptic/block-approval",
"version": "0.0.1"
}
43 changes: 43 additions & 0 deletions packages/blocks/finoptic-approval/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"name": "blocks-finoptic-approval",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/blocks/finoptic-approval/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/packages/blocks/finoptic-approval",
"tsConfig": "packages/blocks/finoptic-approval/tsconfig.lib.json",
"packageJson": "packages/blocks/finoptic-approval/package.json",
"main": "packages/blocks/finoptic-approval/src/index.ts",
"assets": ["packages/blocks/finoptic-approval/*.md"],
"buildableProjectDepsInPackageJsonType": "dependencies",
"updateBuildableProjectDepsInPackageJson": true
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["packages/blocks/finoptic-approval/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "packages/blocks/finoptic-approval/jest.config.ts",
"passWithNoTests": true
},
"configurations": {
"ci": {
"ci": true,
"codeCoverage": true
}
}
}
},
"tags": []
}
15 changes: 15 additions & 0 deletions packages/blocks/finoptic-approval/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { BlockAuth, createBlock } from '@openops/blocks-framework';
import { BlockCategory } from '@openops/shared';
import { supervisorApproval } from './lib/actions/supervisor-approval';

export const finopticApproval = createBlock({
displayName: 'Finoptic Approval',
description: 'Supervisor approval gate for Finoptic automations',
auth: BlockAuth.None(),
minimumSupportedRelease: '0.20.0',
logoUrl: '/blocks/approval-block.svg',
authors: ['finoptic'],
categories: [BlockCategory.CORE],
actions: [supervisorApproval],
triggers: [],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { createAction, Property } from '@openops/blocks-framework';
import { ExecutionType } from '@openops/shared';

export const APPROVALS_PATH = '/internal/automations/approvals';
export const SECRET_HEADER = 'X-Broker-Secret';

/**
* Contract with Finoptic (the same JSON fixture is tested in both repos, see
* test/fixtures in finoptic-aws and finoptic-infra's backend broker README)
* ----------------------------------------------------------------------
* Every call carries the header X-Broker-Secret: $FINOPTIC_BROKER_SECRET.
*
* On BEGIN the step asks Finoptic to put an approval in front of the
* supervisor, then pauses the run (ctx.run.pause, the same mechanism the
* core "Wait for Approval" block uses). Nothing runs until the run resumes.
*
* POST $FINOPTIC_BACKEND_URL/internal/automations/approvals
* { executionId, flowId, flowRunId, summary, approveUrl, rejectUrl }
* 201 = accepted. Anything else fails the step (the run stays unpaused).
*
* approveUrl and rejectUrl come from ctx.generateResumeUrl and go to Finoptic
* only. They are not returned in the step output, so they do not reach the
* run log. The unguessable pause id in the path is the key to resume, so
* Finoptic stores them encrypted and calls them ONLY from its own admin
* decision endpoint.
*
* On RESUME the query action is NOT trusted on its own. The block asks Finoptic:
*
* GET $FINOPTIC_BACKEND_URL/internal/automations/approvals/{executionId}
* 200 { executionId, status } status = pending | approved | rejected
*
* and continues only when action=approve AND status is approved. Anything
* else stops the flow. If Finoptic cannot be reached the step fails: the run
* never continues on an unchecked approval.
*/
export const supervisorApproval = createAction({
name: 'supervisor_approval',
displayName: 'Supervisor Approval (Finoptic)',
description:
'Must be the first step of every Finoptic flow. Asks the supervisor to approve and pauses the flow until they decide. A rejection stops the flow.',
isWriteAction: false,
errorHandlingOptions: {
continueOnFailure: { hide: true },
retryOnFailure: { hide: true },
},
props: {
executionId: Property.ShortText({
displayName: 'Execution ID',
description: 'Finoptic automation execution id',
required: true,
}),
flowId: Property.ShortText({
displayName: 'Flow ID',
description: 'Finoptic automation (flow) id',
required: true,
}),
summary: Property.LongText({
displayName: 'Summary',
description: 'What this flow will do, shown to the supervisor',
required: true,
}),
},
async run(ctx) {
const baseUrl = process.env['FINOPTIC_BACKEND_URL'];
const secret = process.env['FINOPTIC_BROKER_SECRET'];
if (!baseUrl || !secret) {
throw new Error(
'FINOPTIC_BACKEND_URL and FINOPTIC_BROKER_SECRET must be set on the worker',
);
}

const { executionId, flowId, summary } = ctx.propsValue;

if (ctx.executionType === ExecutionType.RESUME) {
const stop = () => {
ctx.run.stop({ response: { body: { rejected: true } } });
return { approved: false };
};
if (ctx.resumePayload.queryParams['action'] !== 'approve') {
return stop();
}
// The URL alone is not proof: ask Finoptic whether an org admin approved.
let check: Response;
try {
check = await fetch(
new URL(
`${APPROVALS_PATH}/${encodeURIComponent(executionId)}`,
baseUrl,
),
{
method: 'GET',
headers: { [SECRET_HEADER]: secret },
signal: AbortSignal.timeout(15_000),
},
);
} catch {
throw new Error('Finoptic approvals endpoint is unreachable');
}
if (!check.ok) {
throw new Error(
`Finoptic could not confirm the approval (HTTP ${check.status})`,
);
}
const body = (await check.json()) as { status?: string };
return body.status === 'approved' ? { approved: true } : stop();
}

const approveUrl = ctx.generateResumeUrl({
queryParams: { action: 'approve' },
});
const rejectUrl = ctx.generateResumeUrl({
queryParams: { action: 'reject' },
});

let response: Response;
try {
response = await fetch(new URL(APPROVALS_PATH, baseUrl), {
method: 'POST',
headers: {
'content-type': 'application/json',
[SECRET_HEADER]: secret,
},
body: JSON.stringify({
executionId,
flowId,
summary,
flowRunId: ctx.run.id,
approveUrl,
rejectUrl,
}),
signal: AbortSignal.timeout(15_000),
});
} catch {
throw new Error('Finoptic approvals endpoint is unreachable');
}
if (!response.ok) {
throw new Error(
`Finoptic did not accept the approval request (HTTP ${response.status})`,
);
}

ctx.run.pause({ pauseMetadata: { response: {} } });
return { approved: false, pending: true }; // not final: the run is paused
},
});
Loading
Loading