Skip to content

Commit eec2df5

Browse files
[One Workflow] feat: add AWS Lambda connector for workflows (elastic#256150)
Adds a new AWS Lambda connector spec that enables workflows to invoke Lambda functions, list available functions, and get function details. Includes SigV4 signing adapted for POST+JSON (Lambda Invoke) alongside GET requests, schema with labeled form fields, SVG icon, and unit tests. Also fixes connector icon resolution in the YAML editor for dot-separated step types (e.g. aws_lambda.invoke) by extracting the base connector name in getBaseConnectorType. Closes elastic/security-team#16126 ```yaml name: "Invoke tinForTheWin Lambda" description: | PoC workflow that invokes the tinForTheWin AWS Lambda function. Demonstrates synchronous invocation with a JSON payload from workflow inputs, listing available functions, and getting function details. triggers: - type: manual tags: - aws - lambda - poc inputs: type: object properties: tin: type: string default: "for-the-win" steps: # ------------------------------------------------------------------------- # Step 1: List all available Lambda functions # ------------------------------------------------------------------------- - name: list_functions type: aws_lambda.listFunctions connector-id: 3bcf7284-b8ff-404f-86ab-35a5ce0172b5 with: maxItems: 10 - name: log_functions type: console with: message: "Available functions: {{ steps.list_functions.output.functions | map: 'functionName' | join: ', ' }}" # ------------------------------------------------------------------------- # Step 2: Get details about the tinForTheWin function # ------------------------------------------------------------------------- - name: get_function_details type: aws_lambda.getFunction connector-id: 3bcf7284-b8ff-404f-86ab-35a5ce0172b5 with: functionName: "tinForTheWin" - name: log_details type: console with: message: "tinForTheWin — runtime: {{ steps.get_function_details.output.runtime }}, memory: {{ steps.get_function_details.output.memorySize }}MB, timeout: {{ steps.get_function_details.output.timeout }}s, state: {{ steps.get_function_details.output.state }}" # ------------------------------------------------------------------------- # Step 3: Invoke the Lambda function # ------------------------------------------------------------------------- - name: invoke_lambda type: aws_lambda.invoke connector-id: 3bcf7284-b8ff-404f-86ab-35a5ce0172b5 with: functionName: "tinForTheWin" payload: "${{ inputs }}" invocationType: "RequestResponse" - name: log_result type: console with: message: "Lambda response: {{ steps.invoke_lambda.output.payload | json }}" enabled: true ``` --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d97bb85 commit eec2df5

14 files changed

Lines changed: 1308 additions & 96 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2461,6 +2461,7 @@ src/platform/packages/shared/kbn-connector-specs/src/specs/github/** @elastic/wo
24612461
src/platform/packages/shared/kbn-connector-specs/src/specs/google_calendar/** @elastic/workchat-eng
24622462
src/platform/packages/shared/kbn-connector-specs/src/specs/google_drive/** @elastic/workchat-eng
24632463
src/platform/packages/shared/kbn-connector-specs/src/specs/greynoise/** @elastic/workflows-eng
2464+
src/platform/packages/shared/kbn-connector-specs/src/specs/aws_lambda/** @elastic/workflows-eng
24642465
src/platform/packages/shared/kbn-connector-specs/src/specs/jina/** @elastic/jinastic
24652466
src/platform/packages/shared/kbn-connector-specs/src/specs/notion/** @elastic/workchat-eng
24662467
src/platform/packages/shared/kbn-connector-specs/src/specs/pagerduty/** @elastic/response-ops

src/platform/packages/shared/kbn-connector-specs/src/all_auth_types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
export * from './auth_types/api_key_header';
11+
export * from './auth_types/aws_credentials';
1112
export * from './auth_types/bearer';
1213
export * from './auth_types/basic';
1314
export * from './auth_types/none';

src/platform/packages/shared/kbn-connector-specs/src/all_specs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
export * from './specs/abuseipdb/abuseipdb';
1111
export * from './specs/alienvault_otx/alienvault_otx';
1212
export * from './specs/atlassian/jira-cloud/jira';
13+
export * from './specs/aws_lambda/aws_lambda';
1314
export * from './specs/brave_search/brave_search';
1415
export * from './specs/github/github';
1516
export * from './specs/google_calendar/google_calendar';
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import { z } from '@kbn/zod/v4';
11+
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
12+
import type { AuthContext, AuthTypeSpec } from '../connector_spec';
13+
import * as i18n from './translations';
14+
15+
// ============================================================================
16+
// SigV4 Signing Utilities
17+
// ============================================================================
18+
19+
const EMPTY_BODY_SHA256 = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
20+
21+
async function sha256Hash(message: string): Promise<string> {
22+
const textEncoder = new TextEncoder();
23+
const data = textEncoder.encode(message);
24+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
25+
const hashArray = Array.from(new Uint8Array(hashBuffer));
26+
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
27+
}
28+
29+
async function hmacSha256(key: BufferSource, message: string): Promise<ArrayBuffer> {
30+
const textEncoder = new TextEncoder();
31+
const messageData = textEncoder.encode(message);
32+
33+
const cryptoKey = await crypto.subtle.importKey(
34+
'raw',
35+
key,
36+
{ name: 'HMAC', hash: 'SHA-256' },
37+
false,
38+
['sign']
39+
);
40+
41+
return await crypto.subtle.sign('HMAC', cryptoKey, messageData);
42+
}
43+
44+
async function calculateSignature(
45+
secretAccessKey: string,
46+
dateStamp: string,
47+
region: string,
48+
service: string,
49+
stringToSign: string
50+
): Promise<string> {
51+
const textEncoder = new TextEncoder();
52+
53+
const kDate = await hmacSha256(textEncoder.encode('AWS4' + secretAccessKey), dateStamp);
54+
const kRegion = await hmacSha256(kDate, region);
55+
const kService = await hmacSha256(kRegion, service);
56+
const kSigning = await hmacSha256(kService, 'aws4_request');
57+
const signature = await hmacSha256(kSigning, stringToSign);
58+
59+
const signatureArray = Array.from(new Uint8Array(signature));
60+
return signatureArray.map((b) => b.toString(16).padStart(2, '0')).join('');
61+
}
62+
63+
/**
64+
* Parse an AWS hostname into service and region.
65+
* Supports: {service}.{region}.amazonaws.com
66+
*/
67+
function parseAwsHost(hostname: string): { service: string; region: string } | null {
68+
if (!hostname.endsWith('.amazonaws.com')) {
69+
return null;
70+
}
71+
const parts = hostname.replace('.amazonaws.com', '').split('.');
72+
if (parts.length < 2) {
73+
return null;
74+
}
75+
return { service: parts[0], region: parts[1] };
76+
}
77+
78+
/**
79+
* Sign an AWS request with SigV4.
80+
* Automatically collects x-amz-* headers for signing (AWS requires them signed).
81+
*/
82+
async function signRequest(
83+
method: string,
84+
host: string,
85+
path: string,
86+
queryParams: Record<string, string>,
87+
accessKeyId: string,
88+
secretAccessKey: string,
89+
region: string,
90+
service: string,
91+
existingHeaders: Record<string, string>,
92+
body?: string
93+
): Promise<Record<string, string>> {
94+
const algorithm = 'AWS4-HMAC-SHA256';
95+
const now = new Date();
96+
const dateStamp = now.toISOString().split('T')[0].replace(/-/g, '');
97+
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
98+
99+
const sortedParams = Object.keys(queryParams).sort();
100+
const canonicalQuerystring = sortedParams
101+
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
102+
.join('&');
103+
104+
const hasBody = body !== undefined && body !== '';
105+
const payloadHash = hasBody ? await sha256Hash(body) : EMPTY_BODY_SHA256;
106+
107+
// Build canonical headers: host + content-type (if body) + x-amz-date + any existing x-amz-* headers
108+
const headersToSign: Record<string, string> = {
109+
host,
110+
'x-amz-date': amzDate,
111+
};
112+
113+
if (hasBody) {
114+
headersToSign['content-type'] = 'application/json';
115+
}
116+
117+
// Include any x-amz-* headers set by the action handler (AWS requires them signed)
118+
for (const [key, value] of Object.entries(existingHeaders)) {
119+
const lowerKey = key.toLowerCase();
120+
if (lowerKey.startsWith('x-amz-') && lowerKey !== 'x-amz-date') {
121+
headersToSign[lowerKey] = value;
122+
}
123+
}
124+
125+
const sortedHeaderKeys = Object.keys(headersToSign).sort();
126+
const canonicalHeaders = sortedHeaderKeys.map((k) => `${k}:${headersToSign[k]}\n`).join('');
127+
const signedHeaders = sortedHeaderKeys.join(';');
128+
129+
const canonicalRequest = [
130+
method,
131+
path,
132+
canonicalQuerystring,
133+
canonicalHeaders,
134+
signedHeaders,
135+
payloadHash,
136+
].join('\n');
137+
138+
const canonicalRequestHash = await sha256Hash(canonicalRequest);
139+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
140+
const stringToSign = [algorithm, amzDate, credentialScope, canonicalRequestHash].join('\n');
141+
142+
const signature = await calculateSignature(
143+
secretAccessKey,
144+
dateStamp,
145+
region,
146+
service,
147+
stringToSign
148+
);
149+
150+
const authorizationHeader = `${algorithm} Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
151+
152+
const result: Record<string, string> = {
153+
Host: host,
154+
'X-Amz-Date': amzDate,
155+
Authorization: authorizationHeader,
156+
};
157+
158+
if (hasBody) {
159+
result['Content-Type'] = 'application/json';
160+
}
161+
162+
return result;
163+
}
164+
165+
// ============================================================================
166+
// Auth Type Definition
167+
// ============================================================================
168+
169+
const authSchema = z
170+
.object({
171+
accessKeyId: z
172+
.string()
173+
.min(1, { message: i18n.AWS_ACCESS_KEY_ID_REQUIRED_MESSAGE })
174+
.meta({ sensitive: true, label: i18n.AWS_ACCESS_KEY_ID_LABEL }),
175+
secretAccessKey: z
176+
.string()
177+
.min(1, { message: i18n.AWS_SECRET_ACCESS_KEY_REQUIRED_MESSAGE })
178+
.meta({ sensitive: true, label: i18n.AWS_SECRET_ACCESS_KEY_LABEL }),
179+
})
180+
.meta({ label: i18n.AWS_CREDENTIALS_LABEL });
181+
182+
type AuthSchemaType = z.infer<typeof authSchema>;
183+
184+
/**
185+
* AWS Credentials Authentication (SigV4)
186+
*
187+
* Adds a request interceptor that automatically signs every outgoing request
188+
* to *.amazonaws.com with AWS Signature V4. Non-AWS URLs pass through unsigned.
189+
*
190+
* Service and region are extracted from the URL hostname pattern:
191+
* {service}.{region}.amazonaws.com
192+
*
193+
* Use for: Lambda, S3, EC2, SES, and any other AWS service.
194+
*/
195+
export const AwsCredentialsAuth: AuthTypeSpec<AuthSchemaType> = {
196+
id: 'aws_credentials',
197+
schema: authSchema,
198+
configure: async (
199+
_: AuthContext,
200+
axiosInstance: AxiosInstance,
201+
secret: AuthSchemaType
202+
): Promise<AxiosInstance> => {
203+
const { accessKeyId, secretAccessKey } = secret;
204+
205+
axiosInstance.interceptors.request.use(
206+
async (config: InternalAxiosRequestConfig): Promise<InternalAxiosRequestConfig> => {
207+
const requestUrl = config.url;
208+
if (!requestUrl) {
209+
return config;
210+
}
211+
212+
// Resolve full URL (handles relative URLs with baseURL)
213+
const fullUrl =
214+
config.baseURL && !requestUrl.startsWith('http')
215+
? new URL(requestUrl, config.baseURL)
216+
: new URL(requestUrl);
217+
218+
const awsInfo = parseAwsHost(fullUrl.hostname);
219+
if (!awsInfo) {
220+
return config;
221+
}
222+
223+
const method = (config.method || 'GET').toUpperCase();
224+
const path = fullUrl.pathname;
225+
const queryParams: Record<string, string> = {};
226+
fullUrl.searchParams.forEach((value, key) => {
227+
queryParams[key] = value;
228+
});
229+
230+
const body =
231+
typeof config.data === 'string'
232+
? config.data
233+
: config.data != null
234+
? JSON.stringify(config.data)
235+
: undefined;
236+
237+
// Collect existing headers for signing
238+
const existingHeaders: Record<string, string> = {};
239+
if (config.headers) {
240+
for (const [key, value] of Object.entries(config.headers.toJSON())) {
241+
if (typeof value === 'string') {
242+
existingHeaders[key] = value;
243+
}
244+
}
245+
}
246+
247+
const sigV4Headers = await signRequest(
248+
method,
249+
fullUrl.hostname,
250+
path,
251+
queryParams,
252+
accessKeyId,
253+
secretAccessKey,
254+
awsInfo.region,
255+
awsInfo.service,
256+
existingHeaders,
257+
body
258+
);
259+
260+
// Apply signed headers to the request
261+
for (const [key, value] of Object.entries(sigV4Headers)) {
262+
config.headers.set(key, value);
263+
}
264+
265+
return config;
266+
}
267+
);
268+
269+
return axiosInstance;
270+
},
271+
};

src/platform/packages/shared/kbn-connector-specs/src/auth_types/translations.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,35 @@ export const PFX_AUTH_VERIFICATION_MODE_LABEL = i18n.translate(
166166
defaultMessage: 'Verification mode',
167167
}
168168
);
169+
170+
export const AWS_CREDENTIALS_LABEL = i18n.translate('connectorSpecs.awsCredentials.label', {
171+
defaultMessage: 'AWS Credentials',
172+
});
173+
174+
export const AWS_ACCESS_KEY_ID_LABEL = i18n.translate(
175+
'connectorSpecs.awsCredentials.accessKeyId.label',
176+
{
177+
defaultMessage: 'Access Key ID',
178+
}
179+
);
180+
181+
export const AWS_ACCESS_KEY_ID_REQUIRED_MESSAGE = i18n.translate(
182+
'connectorSpecs.awsCredentials.accessKeyId.requiredMessage',
183+
{
184+
defaultMessage: 'Access Key ID is required',
185+
}
186+
);
187+
188+
export const AWS_SECRET_ACCESS_KEY_LABEL = i18n.translate(
189+
'connectorSpecs.awsCredentials.secretAccessKey.label',
190+
{
191+
defaultMessage: 'Secret Access Key',
192+
}
193+
);
194+
195+
export const AWS_SECRET_ACCESS_KEY_REQUIRED_MESSAGE = i18n.translate(
196+
'connectorSpecs.awsCredentials.secretAccessKey.requiredMessage',
197+
{
198+
defaultMessage: 'Secret Access Key is required',
199+
}
200+
);

src/platform/packages/shared/kbn-connector-specs/src/connector_icons_map.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,8 @@ export const ConnectorIconsMap: Map<
132132
import(/* webpackChunkName: "connectorIconGoogleCalendar" */ './specs/google_calendar/icon')
133133
),
134134
],
135+
[
136+
'.aws_lambda',
137+
lazy(() => import(/* webpackChunkName: "connectorIconAwsLambda" */ './specs/aws_lambda/icon')),
138+
],
135139
]);

src/platform/packages/shared/kbn-connector-specs/src/connector_spec.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@ export type NormalizedAuthType = AuthTypeSpec<Record<string, unknown>>;
106106
// ============================================================================
107107
// - OAuth2 (clientId, clientSecret, token refresh)
108108
// - SSL/mTLS (certificate-based authentication)
109-
// - AWS SigV4 (AWS service authentication)
110109
// - Custom (connector-specific auth flows)
111110

112111
// ============================================================================

0 commit comments

Comments
 (0)