diff --git a/.github/workflows/finoptic-images.yml b/.github/workflows/finoptic-images.yml new file mode 100644 index 0000000000..47ae5bad06 --- /dev/null +++ b/.github/workflows/finoptic-images.yml @@ -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-: 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 diff --git a/packages/blocks/finoptic-approval/README.md b/packages/blocks/finoptic-approval/README.md new file mode 100644 index 0000000000..0f9ad5172c --- /dev/null +++ b/packages/blocks/finoptic-approval/README.md @@ -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`: + +``` +/v1/flow-runs/{flowRunId}/requests/{pauseId}?action=approve&path=... +/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. diff --git a/packages/blocks/finoptic-approval/jest.config.ts b/packages/blocks/finoptic-approval/jest.config.ts new file mode 100644 index 0000000000..db7fd82578 --- /dev/null +++ b/packages/blocks/finoptic-approval/jest.config.ts @@ -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: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../../coverage/packages/blocks/finoptic-approval', +}; diff --git a/packages/blocks/finoptic-approval/package.json b/packages/blocks/finoptic-approval/package.json new file mode 100644 index 0000000000..724af92d93 --- /dev/null +++ b/packages/blocks/finoptic-approval/package.json @@ -0,0 +1,4 @@ +{ + "name": "@finoptic/block-approval", + "version": "0.0.1" +} diff --git a/packages/blocks/finoptic-approval/project.json b/packages/blocks/finoptic-approval/project.json new file mode 100644 index 0000000000..e7d881c582 --- /dev/null +++ b/packages/blocks/finoptic-approval/project.json @@ -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": [] +} diff --git a/packages/blocks/finoptic-approval/src/index.ts b/packages/blocks/finoptic-approval/src/index.ts new file mode 100644 index 0000000000..8405a6e485 --- /dev/null +++ b/packages/blocks/finoptic-approval/src/index.ts @@ -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: [], +}); diff --git a/packages/blocks/finoptic-approval/src/lib/actions/supervisor-approval.ts b/packages/blocks/finoptic-approval/src/lib/actions/supervisor-approval.ts new file mode 100644 index 0000000000..c1382e0822 --- /dev/null +++ b/packages/blocks/finoptic-approval/src/lib/actions/supervisor-approval.ts @@ -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 + }, +}); diff --git a/packages/blocks/finoptic-approval/test/supervisor-approval.test.ts b/packages/blocks/finoptic-approval/test/supervisor-approval.test.ts new file mode 100644 index 0000000000..63076feb8b --- /dev/null +++ b/packages/blocks/finoptic-approval/test/supervisor-approval.test.ts @@ -0,0 +1,196 @@ +import { ExecutionType } from '@openops/shared'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { supervisorApproval } from '../src/lib/actions/supervisor-approval'; + +// Shared with the Finoptic backend, which tests the same file with Go. +const contract = JSON.parse( + readFileSync( + join(__dirname, '../../finoptic-aws/test/fixtures/contract.json'), + 'utf8', + ), +); + +function statusReply(status: string, ok = true, code = 200) { + return jest.fn().mockResolvedValue({ + ok, + status: code, + json: async () => ({ executionId: 'exec-1', status }), + }) as any; +} + +function ctx( + executionType: ExecutionType, + extra: Record = {}, +) { + return { + executionType, + propsValue: { + executionId: 'exec-1', + flowId: 'flow-1', + summary: 'Stop idle EC2', + }, + run: { id: 'run-1', pause: jest.fn(), stop: jest.fn() }, + generateResumeUrl: jest.fn( + ({ queryParams }) => + `http://app.internal/v1/flow-runs/run-1/requests/p1?action=${queryParams.action}`, + ), + ...extra, + } as any; +} + +describe('supervisor_approval', () => { + beforeEach(() => { + process.env['FINOPTIC_BACKEND_URL'] = 'http://backend.test'; + process.env['FINOPTIC_BROKER_SECRET'] = 'shared'; + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 201 }) as any; + }); + + test('BEGIN posts the request with both resume urls, then pauses', async () => { + const c = ctx(ExecutionType.BEGIN); + const out = (await supervisorApproval.run(c)) as any; + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(String(url)).toBe( + 'http://backend.test/internal/automations/approvals', + ); + expect(init.headers['X-Broker-Secret']).toBe('shared'); + expect(init.headers.authorization).toBeUndefined(); + expect(JSON.parse(init.body)).toEqual({ + executionId: 'exec-1', + flowId: 'flow-1', + summary: 'Stop idle EC2', + flowRunId: 'run-1', + approveUrl: expect.stringContaining('action=approve'), + rejectUrl: expect.stringContaining('action=reject'), + }); + expect(c.run.pause).toHaveBeenCalledTimes(1); + expect(c.run.stop).not.toHaveBeenCalled(); + // resume urls are capabilities: they must not appear in the step output + expect(JSON.stringify(out)).not.toContain('requests/'); + }); + + test('BEGIN does not pause when Finoptic does not accept the request', async () => { + global.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 500 }) as any; + const c = ctx(ExecutionType.BEGIN); + await expect(supervisorApproval.run(c)).rejects.toThrow('HTTP 500'); + expect(c.run.pause).not.toHaveBeenCalled(); + }); + + test('BEGIN fails closed without env', async () => { + delete process.env['FINOPTIC_BACKEND_URL']; + const c = ctx(ExecutionType.BEGIN); + await expect(supervisorApproval.run(c)).rejects.toThrow( + /FINOPTIC_BACKEND_URL/, + ); + expect(c.run.pause).not.toHaveBeenCalled(); + }); + + test('RESUME approve continues only after Finoptic confirms approved', async () => { + global.fetch = statusReply('approved'); + const c = ctx(ExecutionType.RESUME, { + resumePayload: { queryParams: { action: 'approve' } }, + }); + expect(await supervisorApproval.run(c)).toEqual({ approved: true }); + expect(c.run.stop).not.toHaveBeenCalled(); + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(String(url)).toBe( + 'http://backend.test/internal/automations/approvals/exec-1', + ); + expect(init.method).toBe('GET'); + expect(init.headers['X-Broker-Secret']).toBe('shared'); + }); + + test.each([['pending'], ['rejected'], ['something-else']])( + 'RESUME action=approve with Finoptic status %s stops the flow', + async (status) => { + global.fetch = statusReply(status); + const c = ctx(ExecutionType.RESUME, { + resumePayload: { queryParams: { action: 'approve' } }, + }); + expect(await supervisorApproval.run(c)).toEqual({ approved: false }); + expect(c.run.stop).toHaveBeenCalledTimes(1); + }, + ); + + test('RESUME fails closed when Finoptic cannot confirm', async () => { + const c = ctx(ExecutionType.RESUME, { + resumePayload: { queryParams: { action: 'approve' } }, + }); + global.fetch = statusReply('approved', false, 404); + await expect(supervisorApproval.run(c)).rejects.toThrow('HTTP 404'); + global.fetch = jest.fn().mockRejectedValue(new Error('boom')) as any; + await expect(supervisorApproval.run(c)).rejects.toThrow('unreachable'); + expect(c.run.stop).not.toHaveBeenCalled(); + }); + + test.each([['reject'], ['disapprove'], [undefined]])( + 'RESUME with action=%s stops the flow without asking', + async (action) => { + const c = ctx(ExecutionType.RESUME, { + resumePayload: { queryParams: action ? { action } : {} }, + }); + expect(await supervisorApproval.run(c)).toEqual({ approved: false }); + expect(c.run.stop).toHaveBeenCalledTimes(1); + expect(global.fetch).not.toHaveBeenCalled(); + }, + ); +}); + +describe('approval contract fixture', () => { + beforeEach(() => { + process.env['FINOPTIC_BACKEND_URL'] = 'http://backend.test'; + process.env['FINOPTIC_BROKER_SECRET'] = 'shared'; + }); + + test('the create request is exactly the fixture request', async () => { + const req = contract.approvalCreate.request; + global.fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 201 }) as any; + const c = ctx(ExecutionType.BEGIN, { + propsValue: { + executionId: req.executionId, + flowId: req.flowId, + summary: req.summary, + }, + run: { id: req.flowRunId, pause: jest.fn(), stop: jest.fn() }, + generateResumeUrl: ({ queryParams }: any) => + queryParams.action === 'approve' ? req.approveUrl : req.rejectUrl, + }); + await supervisorApproval.run(c); + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(String(url)).toBe( + `http://backend.test${contract.approvalCreate.path}`, + ); + expect(init.headers[contract.secretHeader]).toBe('shared'); + expect(JSON.parse(init.body)).toEqual(req); + }); + + test('the status check reads the fixture response shape and path', async () => { + const res = contract.approvalStatus.response; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => res, + }) as any; + const c = ctx(ExecutionType.RESUME, { + propsValue: { executionId: res.executionId, flowId: 'f', summary: 's' }, + resumePayload: { queryParams: { action: 'approve' } }, + }); + expect(await supervisorApproval.run(c)).toEqual({ approved: true }); + const [url] = (global.fetch as jest.Mock).mock.calls[0]; + expect(String(url)).toBe( + 'http://backend.test' + + contract.approvalStatus.pathTemplate.replace( + '{executionId}', + res.executionId, + ), + ); + }); +}); diff --git a/packages/blocks/finoptic-approval/tsconfig.json b/packages/blocks/finoptic-approval/tsconfig.json new file mode 100644 index 0000000000..1882faa6b3 --- /dev/null +++ b/packages/blocks/finoptic-approval/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/packages/blocks/finoptic-approval/tsconfig.lib.json b/packages/blocks/finoptic-approval/tsconfig.lib.json new file mode 100644 index 0000000000..e583571eac --- /dev/null +++ b/packages/blocks/finoptic-approval/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/packages/blocks/finoptic-approval/tsconfig.spec.json b/packages/blocks/finoptic-approval/tsconfig.spec.json new file mode 100644 index 0000000000..69a251f328 --- /dev/null +++ b/packages/blocks/finoptic-approval/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/packages/blocks/finoptic-aws/README.md b/packages/blocks/finoptic-aws/README.md new file mode 100644 index 0000000000..922906015c --- /dev/null +++ b/packages/blocks/finoptic-aws/README.md @@ -0,0 +1,32 @@ +# blocks-finoptic-aws + +One action, `aws_call`, for Finoptic flows. `BlockAuth.None()`: there is no connection and no stored key. + +Props: `executionId`, `credentialHandle` (sent to the broker as `handle`), `service` (e.g. `ec2`), `operation` (`DescribeInstances` or `describe-instances`), `params` (JSON). + +## Credential path + +Step inputs and outputs are written to the run log, and engines are pooled processes, so: + +- The backend URL and secret are worker env (`FINOPTIC_BACKEND_URL`, `FINOPTIC_BROKER_SECRET`), never props. The region comes from the worker's `AWS_REGION`. +- The wire contract (below) is one JSON fixture, `test/fixtures/contract.json`, tested here (`test/contract.test.ts`) and in the Finoptic backend (Go, `internal/automations/broker/testdata/contract.json`). Keep the two files identical. +- The AWS SDK v3 client is created in memory per call and destroyed after it. Nothing is cached at module level and nothing is written to disk or `process.env`. +- The output is only the API result (`$metadata` removed). Credential fields (`AccessKeyId`, `SecretAccessKey`, `SessionToken`) and AKIA/ASIA-shaped strings are replaced with `[REDACTED]`, so an operation that returns credentials (`sts:AssumeRole`) does not put them in the log. +- Errors are rethrown as a new error with redacted text. Broker errors report the HTTP status only. + +## Wire contract with the Finoptic backend + +Header `X-Broker-Secret: $FINOPTIC_BROKER_SECRET` on every call. + +``` +POST $FINOPTIC_BACKEND_URL/internal/broker/resolve +{ "executionId": "...", "handle": "..." } +200 { "accessKeyId", "secretAccessKey", "sessionToken", "expiration" } (expiration is RFC 3339) +403 { "error": "denied" } +``` + +The broker answers 403 (one generic error) when the handle is unknown, already used, expired, bound to another execution, or the execution has no APPROVED supervisor approval. A flow that skips `supervisor_approval` gets no credentials. + +## Services + +The block loads `@aws-sdk/client-` at run time. Only clients installed in the image work; the root `package.json` has ec2, rds, s3, ssm, sts and more. Add a package there to allow another service. diff --git a/packages/blocks/finoptic-aws/jest.config.ts b/packages/blocks/finoptic-aws/jest.config.ts new file mode 100644 index 0000000000..5a610f0e20 --- /dev/null +++ b/packages/blocks/finoptic-aws/jest.config.ts @@ -0,0 +1,11 @@ +export default { + displayName: 'blocks-finoptic-aws', + preset: '../../../jest.preset.js', + setupFiles: ['../../../jest.env.js'], + testEnvironment: 'node', + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../../coverage/packages/blocks/finoptic-aws', +}; diff --git a/packages/blocks/finoptic-aws/package.json b/packages/blocks/finoptic-aws/package.json new file mode 100644 index 0000000000..86e0ce37e0 --- /dev/null +++ b/packages/blocks/finoptic-aws/package.json @@ -0,0 +1,4 @@ +{ + "name": "@finoptic/block-aws", + "version": "0.0.1" +} diff --git a/packages/blocks/finoptic-aws/project.json b/packages/blocks/finoptic-aws/project.json new file mode 100644 index 0000000000..cf179b77e2 --- /dev/null +++ b/packages/blocks/finoptic-aws/project.json @@ -0,0 +1,43 @@ +{ + "name": "blocks-finoptic-aws", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/blocks/finoptic-aws/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/packages/blocks/finoptic-aws", + "tsConfig": "packages/blocks/finoptic-aws/tsconfig.lib.json", + "packageJson": "packages/blocks/finoptic-aws/package.json", + "main": "packages/blocks/finoptic-aws/src/index.ts", + "assets": ["packages/blocks/finoptic-aws/*.md"], + "buildableProjectDepsInPackageJsonType": "dependencies", + "updateBuildableProjectDepsInPackageJson": true + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/blocks/finoptic-aws/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/blocks/finoptic-aws/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "tags": [] +} diff --git a/packages/blocks/finoptic-aws/src/index.ts b/packages/blocks/finoptic-aws/src/index.ts new file mode 100644 index 0000000000..32e03ecc1a --- /dev/null +++ b/packages/blocks/finoptic-aws/src/index.ts @@ -0,0 +1,16 @@ +import { BlockAuth, createBlock } from '@openops/blocks-framework'; +import { BlockCategory } from '@openops/shared'; +import { awsCall } from './lib/actions/aws-call'; + +export const finopticAws = createBlock({ + displayName: 'Finoptic AWS', + description: + 'Runs AWS calls with short-lived credentials issued by the Finoptic broker', + auth: BlockAuth.None(), + minimumSupportedRelease: '0.20.0', + logoUrl: '/blocks/aws.png', + authors: ['finoptic'], + categories: [BlockCategory.CLOUD], + actions: [awsCall], + triggers: [], +}); diff --git a/packages/blocks/finoptic-aws/src/lib/actions/aws-call.ts b/packages/blocks/finoptic-aws/src/lib/actions/aws-call.ts new file mode 100644 index 0000000000..0026f01674 --- /dev/null +++ b/packages/blocks/finoptic-aws/src/lib/actions/aws-call.ts @@ -0,0 +1,132 @@ +import { createAction, Property } from '@openops/blocks-framework'; +import { BrokerCredentials, fetchCredentials } from '../broker'; +import { redactText, redactValue } from '../redact'; + +const SERVICE_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const OPERATION_PATTERN = /^[A-Za-z][A-Za-z0-9-_]*$/; + +function toPascalCase(operation: string): string { + return operation + .split(/[-_]/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(''); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyCtor = new (...args: any[]) => any; + +function loadSdk(service: string): Record { + try { + // Dynamic on purpose: Finoptic flows name the service. The name is + // validated above, and only packages installed in the image can load. + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require(`@aws-sdk/client-${service}`); + } catch { + throw new Error( + `AWS service "${service}" is not available in this image (no @aws-sdk/client-${service})`, + ); + } +} + +export const awsCall = createAction({ + name: 'aws_call', + displayName: 'AWS Call (Finoptic)', + description: + 'Runs one AWS API call with short-lived credentials from the Finoptic broker. Credentials never reach the run log.', + isWriteAction: true, + errorHandlingOptions: { + continueOnFailure: { hide: true }, + retryOnFailure: { hide: true }, + }, + props: { + executionId: Property.ShortText({ + displayName: 'Execution ID', + description: 'Finoptic automation execution id', + required: true, + }), + credentialHandle: Property.ShortText({ + displayName: 'Credential handle', + description: + 'Opaque handle the Finoptic broker exchanges for credentials', + required: true, + }), + service: Property.ShortText({ + displayName: 'Service', + description: 'AWS SDK v3 client name, e.g. ec2, rds, s3', + required: true, + }), + operation: Property.ShortText({ + displayName: 'Operation', + description: + 'API operation, e.g. DescribeInstances or describe-instances', + required: true, + }), + params: Property.Json({ + displayName: 'Parameters', + description: 'Input of the API call', + required: false, + defaultValue: {}, + }), + }, + async run(ctx) { + const { executionId, credentialHandle, service, operation, params } = + ctx.propsValue; + + if (!SERVICE_PATTERN.test(service)) { + throw new Error('Invalid service name'); + } + if (!OPERATION_PATTERN.test(operation)) { + throw new Error('Invalid operation name'); + } + + let creds: BrokerCredentials | undefined; + // Anything that must never appear in text we hand back to OpenOps. + let known: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let client: any; + try { + creds = await fetchCredentials(executionId, credentialHandle); + known = [creds.accessKeyId, creds.secretAccessKey, creds.sessionToken]; + + const sdk = loadSdk(service); + const clientName = Object.keys(sdk).find( + (k) => k.endsWith('Client') && typeof sdk[k] === 'function', + ); + const commandName = `${toPascalCase(operation)}Command`; + const Command = sdk[commandName] as AnyCtor | undefined; + if (!clientName || typeof Command !== 'function') { + throw new Error(`Unknown operation ${service}.${operation}`); + } + + const Client = sdk[clientName] as AnyCtor; + client = new Client({ + // The broker sends no region: the worker's AWS_REGION decides. + region: process.env['AWS_REGION'] ?? 'us-east-1', + credentials: { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }, + }); + + const result = await client.send(new Command(params ?? {})); + const { $metadata: _metadata, ...apiResult } = result ?? {}; + return redactValue(apiResult, known); + } catch (error) { + // A new Error: the original can carry request details, and its stack + // is not ours to publish. Message and name go through redaction. + const e = error as { name?: string; message?: string }; + const message = redactText( + `${e?.name ?? 'Error'}: ${e?.message ?? 'call failed'}`, + known, + { bareTokens: true }, + ); + throw new Error(`AWS ${service}.${operation} failed: ${message}`); + } finally { + client?.destroy?.(); + creds = undefined; + known = []; + } + }, +}); diff --git a/packages/blocks/finoptic-aws/src/lib/broker.ts b/packages/blocks/finoptic-aws/src/lib/broker.ts new file mode 100644 index 0000000000..2bb7d60d45 --- /dev/null +++ b/packages/blocks/finoptic-aws/src/lib/broker.ts @@ -0,0 +1,74 @@ +export type BrokerCredentials = { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: string; +}; + +export class BrokerError extends Error {} + +export const RESOLVE_PATH = '/internal/broker/resolve'; +export const SECRET_HEADER = 'X-Broker-Secret'; + +/** + * Asks the Finoptic credential broker for short-lived credentials. + * + * Contract (the same JSON fixture is tested in both repos, see + * test/fixtures/contract.json and finoptic-infra's backend broker README): + * POST $FINOPTIC_BACKEND_URL/internal/broker/resolve + * X-Broker-Secret: $FINOPTIC_BROKER_SECRET + * {"executionId": "...", "handle": "..."} + * 200 {"accessKeyId", "secretAccessKey", "sessionToken", "expiration"} + * The broker refuses (403) when the handle is unknown, used, expired, or the + * execution has no approved supervisor approval, so a flow that skips the + * approval step gets no credentials. The handle works once. + * + * URL and secret come from the worker environment, never from step props + * (props are written to the run log). Nothing here is cached. + */ +export async function fetchCredentials( + executionId: string, + handle: string, +): Promise { + const baseUrl = process.env['FINOPTIC_BACKEND_URL']; + const secret = process.env['FINOPTIC_BROKER_SECRET']; + if (!baseUrl || !secret) { + throw new BrokerError( + 'FINOPTIC_BACKEND_URL and FINOPTIC_BROKER_SECRET must be set on the worker', + ); + } + + let response: Response; + try { + response = await fetch(new URL(RESOLVE_PATH, baseUrl), { + method: 'POST', + headers: { + 'content-type': 'application/json', + [SECRET_HEADER]: secret, + }, + body: JSON.stringify({ executionId, handle }), + signal: AbortSignal.timeout(15_000), + }); + } catch { + throw new BrokerError('Credential broker is unreachable'); + } + + if (!response.ok) { + // Status only: the body of a broker error is not trusted to be free of + // secrets, and the caller only needs to know it was refused. + throw new BrokerError( + `Credential broker refused the request (HTTP ${response.status})`, + ); + } + + const body = (await response.json()) as Partial; + if (!body.accessKeyId || !body.secretAccessKey || !body.sessionToken) { + throw new BrokerError('Credential broker returned an incomplete response'); + } + return { + accessKeyId: body.accessKeyId, + secretAccessKey: body.secretAccessKey, + sessionToken: body.sessionToken, + expiration: body.expiration ?? '', + }; +} diff --git a/packages/blocks/finoptic-aws/src/lib/redact.ts b/packages/blocks/finoptic-aws/src/lib/redact.ts new file mode 100644 index 0000000000..02db33ce72 --- /dev/null +++ b/packages/blocks/finoptic-aws/src/lib/redact.ts @@ -0,0 +1,64 @@ +export const REDACTED = '[REDACTED]'; + +const CREDENTIAL_KEY = /^(accesskeyid|secretaccesskey|sessiontoken)$/i; + +// key: value / key=value / "key":"value" in free text (error messages, JSON) +const KEY_VALUE_PATTERN = + /(AccessKeyId|SecretAccessKey|SessionToken|aws_access_key_id|aws_secret_access_key|aws_session_token)(["']?\s*[:=]\s*["']?)[^\s"',;}]+/gi; + +// AWS key id shapes: long-term (AKIA), temporary (ASIA), and the other +// IAM unique-id prefixes that identify a principal's key material. +const KEY_ID_PATTERN = + /\b(?:AKIA|ASIA|AIDA|AROA|AIPA|ANPA|ANVA)[A-Z0-9]{12,}\b/g; + +// Session tokens from STS are long base64 blobs; catch a bare one too. Only +// for error text: in API results a long base64 string can be legitimate data +// (EC2 user data, for one). +const LONG_TOKEN_PATTERN = /\b[A-Za-z0-9+/=]{100,}\b/g; + +/** + * Removes anything that looks like AWS credential material from text. + * `known` are exact values (the credentials we just fetched, the broker + * secret) that are removed wherever they appear, whatever their shape. + */ +export function redactText( + text: string, + known: string[] = [], + { bareTokens = false }: { bareTokens?: boolean } = {}, +): string { + let out = text; + for (const value of known) { + if (value && value.length >= 8) { + out = out.split(value).join(REDACTED); + } + } + out = out + .replace(KEY_VALUE_PATTERN, `$1$2${REDACTED}`) + .replace(KEY_ID_PATTERN, REDACTED); + return bareTokens ? out.replace(LONG_TOKEN_PATTERN, REDACTED) : out; +} + +/** + * Deep copy of an API result with credential fields and credential-shaped + * strings replaced. An operation that legitimately returns credentials + * (sts AssumeRole, iam CreateAccessKey) must not put them in the run log. + */ +export function redactValue(value: unknown, known: string[] = []): unknown { + if (typeof value === 'string') { + return redactText(value, known); + } + if (Array.isArray(value)) { + return value.map((v) => redactValue(v, known)); + } + if (value instanceof Date) { + return value.toISOString(); + } + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + out[k] = CREDENTIAL_KEY.test(k) ? REDACTED : redactValue(v, known); + } + return out; + } + return value; +} diff --git a/packages/blocks/finoptic-aws/test/aws-call.test.ts b/packages/blocks/finoptic-aws/test/aws-call.test.ts new file mode 100644 index 0000000000..6d06d192dd --- /dev/null +++ b/packages/blocks/finoptic-aws/test/aws-call.test.ts @@ -0,0 +1,155 @@ +const send = jest.fn(); +const destroy = jest.fn(); +const clientCtor = jest.fn(); + +class FakeClient { + constructor(config: unknown) { + clientCtor(config); + } + send = send; + destroy = destroy; +} +class DescribeInstancesCommand { + constructor(public input: unknown) {} +} + +jest.mock('@aws-sdk/client-ec2', () => ({ + EC2Client: FakeClient, + DescribeInstancesCommand, +})); + +import { awsCall } from '../src/lib/actions/aws-call'; + +const CREDS = { + accessKeyId: 'ASIATESTKEYID123456', + secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY', + sessionToken: 'FwoGZXIvYXdzEXAMPLETOKEN/with+chars==', + expiration: '2033-05-18T03:33:20Z', +}; + +function ctx(overrides: Record = {}) { + return { + propsValue: { + executionId: 'exec-1', + credentialHandle: 'handle-1', + service: 'ec2', + operation: 'DescribeInstances', + params: { MaxResults: 5 }, + ...overrides, + }, + } as any; +} + +function brokerReturns(body: unknown, status = 200) { + global.fetch = jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }) as any; +} + +describe('aws_call', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env['FINOPTIC_BACKEND_URL'] = 'http://broker.test'; + process.env['AWS_REGION'] = 'eu-west-1'; + process.env['FINOPTIC_BROKER_SECRET'] = 'broker-shared-secret'; + }); + + test('sends handle + secret from env, calls AWS, returns only the API result', async () => { + brokerReturns(CREDS); + send.mockResolvedValue({ + Reservations: [{ Instances: [{ InstanceId: 'i-1' }] }], + $metadata: { requestId: 'r', httpStatusCode: 200 }, + }); + + const result = await awsCall.run(ctx()); + + expect(result).toEqual({ + Reservations: [{ Instances: [{ InstanceId: 'i-1' }] }], + }); + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(String(url)).toBe('http://broker.test/internal/broker/resolve'); + expect(init.headers['X-Broker-Secret']).toBe('broker-shared-secret'); + expect(JSON.parse(init.body)).toEqual({ + executionId: 'exec-1', + handle: 'handle-1', + }); + expect(clientCtor).toHaveBeenCalledWith( + expect.objectContaining({ region: 'eu-west-1' }), + ); + expect(send.mock.calls[0][0].input).toEqual({ MaxResults: 5 }); + expect(destroy).toHaveBeenCalled(); + }); + + test('accepts kebab-case operation names', async () => { + brokerReturns(CREDS); + send.mockResolvedValue({}); + await awsCall.run(ctx({ operation: 'describe-instances' })); + expect(send.mock.calls[0][0]).toBeInstanceOf(DescribeInstancesCommand); + }); + + test('credentials returned by the API are redacted from the output', async () => { + brokerReturns(CREDS); + send.mockResolvedValue({ + Credentials: { + AccessKeyId: 'ASIAOTHERKEY1234567', + SecretAccessKey: 'othersecret', + SessionToken: 'othertoken', + }, + Echo: CREDS.secretAccessKey, + }); + const out = JSON.stringify(await awsCall.run(ctx())); + expect(out).not.toMatch(/ASIA|othersecret|othertoken|wJalr/); + }); + + test('credentials in an SDK error are redacted from the thrown error', async () => { + brokerReturns(CREDS); + send.mockRejectedValue( + Object.assign( + new Error( + `denied for ${CREDS.accessKeyId} secret ${CREDS.secretAccessKey} SessionToken=${CREDS.sessionToken} AKIAABCDEFGHIJKLMNOP`, + ), + { name: 'UnauthorizedOperation' }, + ), + ); + const error = (await awsCall.run(ctx()).catch((e: Error) => e)) as Error; + expect(error).toBeInstanceOf(Error); + const text = `${error.message}\n${error.stack}`; + expect(text).not.toMatch(/ASIATEST|AKIAABCD|wJalr|FwoGZ/); + expect(error.message).toContain('UnauthorizedOperation'); + expect(destroy).toHaveBeenCalled(); + }); + + test('a refused broker call fails without echoing the broker body or secret', async () => { + brokerReturns({ error: 'broker-shared-secret leaked?' }, 403); + const error = (await awsCall.run(ctx()).catch((e: Error) => e)) as Error; + expect(error.message).toContain('HTTP 403'); + expect(error.message).not.toContain('broker-shared-secret'); + expect(send).not.toHaveBeenCalled(); + }); + + test('fails closed when the broker env is missing', async () => { + delete process.env['FINOPTIC_BACKEND_URL']; + global.fetch = jest.fn() as any; + await expect(awsCall.run(ctx())).rejects.toThrow(/FINOPTIC_BACKEND_URL/); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('rejects service names that are not plain client names', async () => { + global.fetch = jest.fn() as any; + await expect(awsCall.run(ctx({ service: '../x' }))).rejects.toThrow( + 'Invalid service name', + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + test('fetches fresh credentials on every call (no caching)', async () => { + brokerReturns(CREDS); + send.mockResolvedValue({}); + await awsCall.run(ctx()); + await awsCall.run(ctx()); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(clientCtor).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/blocks/finoptic-aws/test/contract.test.ts b/packages/blocks/finoptic-aws/test/contract.test.ts new file mode 100644 index 0000000000..cc0ec7d2ca --- /dev/null +++ b/packages/blocks/finoptic-aws/test/contract.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { + fetchCredentials, + RESOLVE_PATH, + SECRET_HEADER, +} from '../src/lib/broker'; + +// The wire contract with the Finoptic backend. The same file is checked in to +// finoptic-infra's backend (internal/automations/broker/testdata/contract.json) +// and tested there with Go. Keep the two files identical. +const contract = JSON.parse( + readFileSync(join(__dirname, 'fixtures', 'contract.json'), 'utf8'), +); + +describe('broker contract fixture', () => { + beforeEach(() => { + process.env['FINOPTIC_BACKEND_URL'] = 'http://backend.test'; + process.env['FINOPTIC_BROKER_SECRET'] = 'shared'; + }); + + test('path and header match the fixture', () => { + expect(RESOLVE_PATH).toBe(contract.resolve.path); + expect(SECRET_HEADER).toBe(contract.secretHeader); + }); + + test('the request the block sends is exactly the fixture request', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => contract.resolve.response, + }) as any; + + await fetchCredentials( + contract.resolve.request.executionId, + contract.resolve.request.handle, + ); + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(String(url)).toBe(`http://backend.test${contract.resolve.path}`); + expect(init.method).toBe('POST'); + expect(init.headers[contract.secretHeader]).toBe('shared'); + expect(JSON.parse(init.body)).toEqual(contract.resolve.request); + }); + + test('the fixture response parses with every field the backend sends', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => contract.resolve.response, + }) as any; + + const creds = await fetchCredentials('exec-1', 'h'); + + expect(creds).toEqual(contract.resolve.response); + expect(Object.keys(creds).sort()).toEqual( + Object.keys(contract.resolve.response).sort(), + ); + }); +}); diff --git a/packages/blocks/finoptic-aws/test/fixtures/contract.json b/packages/blocks/finoptic-aws/test/fixtures/contract.json new file mode 100644 index 0000000000..068eebfead --- /dev/null +++ b/packages/blocks/finoptic-aws/test/fixtures/contract.json @@ -0,0 +1,34 @@ +{ + "secretHeader": "X-Broker-Secret", + "resolve": { + "path": "/internal/broker/resolve", + "request": { + "executionId": "exec-1", + "handle": "dGhpcy1pcy1hLXRlc3QtaGFuZGxlLW5vdC1yZWFsISE" + }, + "response": { + "accessKeyId": "ASIAEXAMPLEKEYID1234", + "secretAccessKey": "example-secret-access-key-material", + "sessionToken": "example-session-token-material", + "expiration": "2033-05-18T03:33:20Z" + } + }, + "approvalCreate": { + "path": "/internal/automations/approvals", + "request": { + "executionId": "exec-1", + "flowId": "flow-1", + "flowRunId": "run-1", + "summary": "Stop idle EC2 instances", + "approveUrl": "http://openops.internal:3000/v1/flow-runs/run-1/requests/pause-abc?action=approve", + "rejectUrl": "http://openops.internal:3000/v1/flow-runs/run-1/requests/pause-abc?action=reject" + } + }, + "approvalStatus": { + "pathTemplate": "/internal/automations/approvals/{executionId}", + "response": { + "executionId": "exec-1", + "status": "approved" + } + } +} diff --git a/packages/blocks/finoptic-aws/test/redact.test.ts b/packages/blocks/finoptic-aws/test/redact.test.ts new file mode 100644 index 0000000000..8fb84f9b79 --- /dev/null +++ b/packages/blocks/finoptic-aws/test/redact.test.ts @@ -0,0 +1,44 @@ +import { redactText, redactValue } from '../src/lib/redact'; + +describe('redactText', () => { + test('removes key ids and key=value pairs', () => { + const text = + 'bad creds ASIAABCDEFGHIJKLMNOP and AKIAABCDEFGHIJKLMNOP; SecretAccessKey=abc123/XYZ+secret AccessKeyId: "ASIAZZZZZZZZZZZZZZZZ" "SessionToken":"tok/en+value=="'; + const out = redactText(text); + expect(out).not.toMatch(/ASIA|AKIA|abc123|tok\/en/); + expect(out).toContain('[REDACTED]'); + }); + + test('removes known exact values of any shape', () => { + expect( + redactText('boom my-odd-secret-value!', ['my-odd-secret-value!']), + ).toBe('boom [REDACTED]'); + }); + + test('bare long tokens only when asked', () => { + const token = 'A'.repeat(120); + expect(redactText(`x ${token}`)).toContain(token); + expect(redactText(`x ${token}`, [], { bareTokens: true })).not.toContain( + token, + ); + }); +}); + +describe('redactValue', () => { + test('blanks credential fields at any depth and keeps the rest', () => { + const out = redactValue({ + Credentials: { + AccessKeyId: 'ASIAABCDEFGHIJKLMNOP', + SecretAccessKey: 's3cr3t', + SessionToken: 'tok', + Expiration: new Date('2030-01-01T00:00:00Z'), + }, + Items: [{ Name: 'ok', Note: 'key AKIAABCDEFGHIJKLMNOP here' }], + }); + expect(JSON.stringify(out)).not.toMatch(/ASIA|AKIA|s3cr3t|"tok"/); + expect((out as any).Items[0].Name).toBe('ok'); + expect((out as any).Credentials.Expiration).toBe( + '2030-01-01T00:00:00.000Z', + ); + }); +}); diff --git a/packages/blocks/finoptic-aws/tsconfig.json b/packages/blocks/finoptic-aws/tsconfig.json new file mode 100644 index 0000000000..1882faa6b3 --- /dev/null +++ b/packages/blocks/finoptic-aws/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "compilerOptions": { + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/packages/blocks/finoptic-aws/tsconfig.lib.json b/packages/blocks/finoptic-aws/tsconfig.lib.json new file mode 100644 index 0000000000..e583571eac --- /dev/null +++ b/packages/blocks/finoptic-aws/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/packages/blocks/finoptic-aws/tsconfig.spec.json b/packages/blocks/finoptic-aws/tsconfig.spec.json new file mode 100644 index 0000000000..69a251f328 --- /dev/null +++ b/packages/blocks/finoptic-aws/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 33ddec3ab9..cee1737a3c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -29,6 +29,10 @@ "@openops/block-asana": ["packages/blocks/asana/src/index.ts"], "@openops/block-assemblyai": ["packages/blocks/assemblyai/src/index.ts"], "@openops/block-aws": ["packages/blocks/aws/src/index.ts"], + "@finoptic/block-aws": ["packages/blocks/finoptic-aws/src/index.ts"], + "@finoptic/block-approval": [ + "packages/blocks/finoptic-approval/src/index.ts" + ], "@openops/block-aws-athena": ["packages/blocks/aws-athena/src/index.ts"], "@openops/block-aws-compute-optimizer": [ "packages/blocks/aws-compute-optimizer/src/index.ts"