Skip to content

Commit 1c12de3

Browse files
committed
WIP
1 parent e2ef52d commit 1c12de3

File tree

6 files changed

+533
-0
lines changed

6 files changed

+533
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/bin/
2+
/node_modules/
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
name: aws-ts-lambda-external-secrets
2+
runtime: nodejs
3+
description: External secrets adapter for Pulumi ESC using AWS Lambda with JWT authentication
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
[![Deploy this example with Pulumi](https://get.pulumi.com/new/button.svg)](https://app.pulumi.com/new?template=https://github.com/pulumi/examples/blob/master/aws-ts-lambda-external-secrets/README.md#gh-light-mode-only)
2+
[![Deploy this example with Pulumi](https://get.pulumi.com/new/button-light.svg)](https://app.pulumi.com/new?template=https://github.com/pulumi/examples/blob/master/aws-ts-lambda-external-secrets/README.md#gh-dark-mode-only)
3+
4+
# External secrets adapter for Pulumi ESC on AWS Lambda
5+
6+
An educational example demonstrating how to build a secure external secrets adapter for Pulumi ESC using AWS Lambda. This echo adapter validates JWT authentication and request integrity checking, serving as a reference implementation for custom integrations.
7+
8+
## Overview
9+
10+
This example shows how to integrate custom or proprietary secret sources with Pulumi ESC using the [external provider](/docs/esc/integrations/dynamic-secrets/external/). Instead of waiting for a native provider implementation, you can build your own HTTPS adapter service that:
11+
12+
- Authenticates requests using JWT tokens issued by Pulumi Cloud
13+
- Verifies request integrity using SHA-256 body hashes
14+
- Returns secrets back to ESC in any format you choose
15+
16+
This echo adapter demonstrates the security validation pattern without connecting to an actual secret store, making it an ideal starting point for building your own custom integrations.
17+
18+
## What this example demonstrates
19+
20+
- **JWT authentication**: Verifying RS256-signed tokens using Pulumi Cloud's JWKS endpoint
21+
- **Request integrity checking**: Validating SHA-256 SRI hashes to prevent replay attacks
22+
- **Secure HTTPS webhook endpoint**: API Gateway fronting a Lambda function
23+
- **Inline Lambda handler**: Using Pulumi's function serialization for simple deployment
24+
- **CloudWatch logging**: Server-side logging of JWT claims for debugging
25+
26+
## Architecture
27+
28+
This example provisions:
29+
30+
- **AWS Lambda Function**: Executes the adapter validation logic (inline handler)
31+
- **Amazon API Gateway (REST)**: Provides public HTTPS endpoint
32+
- **IAM Role**: Grants Lambda permissions for CloudWatch Logs
33+
- **CloudWatch Logs**: Captures Lambda execution logs for monitoring
34+
35+
The adapter validates requests from Pulumi ESC by:
36+
37+
1. Extracting the JWT from the `Authorization` header
38+
2. Verifying the JWT signature against Pulumi Cloud's JWKS
39+
3. Checking the JWT audience claim matches the adapter URL
40+
4. Computing the request body hash and comparing it to the JWT's `body_hash` claim
41+
5. Echoing back the request with a timestamp (demonstrating successful validation)
42+
43+
## Prerequisites
44+
45+
- AWS account with appropriate permissions to create Lambda functions and API Gateway APIs
46+
- [Pulumi CLI](https://www.pulumi.com/docs/install/) installed
47+
- [Node.js](https://nodejs.org/) 18+ and npm
48+
- AWS credentials configured (via `aws configure` or environment variables)
49+
- [Pulumi ESC](https://www.pulumi.com/product/esc/) access (free tier available)
50+
51+
## Deploying the adapter
52+
53+
1. Clone the repository:
54+
55+
```bash
56+
git clone https://github.com/pulumi/examples.git
57+
cd examples/aws-ts-lambda-external-secrets
58+
```
59+
60+
1. Install dependencies:
61+
62+
```bash
63+
npm install
64+
```
65+
66+
1. Create a new Pulumi stack:
67+
68+
```bash
69+
pulumi stack init dev
70+
```
71+
72+
1. Configure your AWS region:
73+
74+
```bash
75+
pulumi config set aws:region us-west-2
76+
```
77+
78+
1. Deploy the infrastructure:
79+
80+
```bash
81+
pulumi up
82+
```
83+
84+
Review the proposed changes and select "yes" to deploy.
85+
86+
1. Note the adapter URL from the stack output:
87+
88+
```bash
89+
export ADAPTER_URL=$(pulumi stack output adapterUrl)
90+
echo "Adapter URL: $ADAPTER_URL"
91+
```
92+
93+
The URL will look like: `https://abc123xyz.execute-api.us-west-2.amazonaws.com/stage/`
94+
95+
## Using with Pulumi ESC
96+
97+
Once deployed, create a Pulumi ESC environment to test the adapter:
98+
99+
1. Create a new environment (via [Pulumi Cloud Console](https://app.pulumi.com/) or CLI):
100+
101+
```bash
102+
esc env init <your-org>/external-demo
103+
```
104+
105+
1. Edit the environment definition:
106+
107+
```yaml
108+
values:
109+
demo:
110+
fn::open::external:
111+
url: https://YOUR-API-ID.execute-api.us-west-2.amazonaws.com/stage/
112+
request:
113+
message: "Hello from ESC!"
114+
environment: "production"
115+
```
116+
117+
Replace `YOUR-API-ID` with your actual API Gateway URL from the stack output.
118+
119+
1. Open the environment to test the adapter:
120+
121+
```bash
122+
esc open <your-org>/external-demo
123+
```
124+
125+
1. Expected output:
126+
127+
```json
128+
{
129+
"demo": {
130+
"response": {
131+
"message": "External secrets adapter responding successfully!",
132+
"requestEcho": {
133+
"message": "Hello from ESC!",
134+
"environment": "production"
135+
},
136+
"timestamp": "2025-11-26T12:00:00.000Z"
137+
}
138+
}
139+
}
140+
```
141+
142+
The response appears under `demo.response` and is automatically marked as secret by ESC (unless you set `secret: false` in the provider configuration).
143+
144+
## What's happening under the hood
145+
146+
When you open an ESC environment with an external provider, the following sequence occurs:
147+
148+
1. **ESC generates JWT**: Pulumi ESC creates a signed JWT token containing:
149+
- Your organization and environment identity
150+
- The adapter URL as the audience (`aud` claim)
151+
- SHA-256 hash of the request body (`body_hash` claim)
152+
- Expiration time and other standard claims
153+
154+
1. **ESC calls your adapter**: ESC makes an HTTPS POST request to your Lambda via API Gateway:
155+
- `Authorization: Bearer <jwt-token>` header
156+
- JSON body containing your `request` configuration
157+
158+
1. **Lambda validates the request**:
159+
- Extracts the JWT from the Authorization header
160+
- Fetches Pulumi's public signing key from JWKS (`https://api.pulumi.com/.well-known/jwks.json`)
161+
- Verifies the JWT signature using the RS256 algorithm
162+
- Checks that the audience claim matches the adapter's URL
163+
- Computes the SHA-256 hash of the request body
164+
- Verifies the computed hash matches the `body_hash` claim in the JWT
165+
166+
1. **Adapter responds**: If validation succeeds, the Lambda returns a JSON response that becomes available under `demo.response` in your ESC environment
167+
168+
1. **ESC marks as secret**: By default, the entire response is marked as secret in ESC (shown as `[secret]` in output unless specifically accessed)
169+
170+
## Monitoring and debugging
171+
172+
View Lambda execution logs:
173+
174+
```bash
175+
pulumi logs --follow
176+
```
177+
178+
Or use the AWS CLI to tail CloudWatch Logs:
179+
180+
```bash
181+
aws logs tail /aws/lambda/$(pulumi stack output functionName) --follow
182+
```
183+
184+
The Lambda handler logs JWT claims to CloudWatch for debugging:
185+
186+
```
187+
JWT validation successful
188+
Organization: acme-corp
189+
Environment: acme-corp/external-demo
190+
Trigger User: alice
191+
Issued At: 2025-11-26T12:00:00.000Z
192+
```
193+
194+
### Common issues
195+
196+
- **401 Unauthorized**: JWT signature verification failed. Check that the JWKS URL is correct and accessible.
197+
- **401 Audience mismatch**: The adapter URL in your ESC environment doesn't match the actual API Gateway URL. Verify the URL is exact (including trailing slash if present).
198+
- **400 Body hash verification failed**: The request body was modified in transit, or there's a bug in the hash computation. This should not happen in normal operation.
199+
- **500 Internal server error**: Check CloudWatch Logs for the specific error message.
200+
201+
## Building your own adapter
202+
203+
This example is intentionally simple (echo adapter). To integrate a real secret source:
204+
205+
1. **Keep the security validation**: The JWT and body hash verification code is critical and should be preserved in any custom adapter.
206+
207+
1. **Parse the request**: Extract parameters from the `request` body to determine which secrets to fetch:
208+
209+
```typescript
210+
const { secretName, environment } = request;
211+
```
212+
213+
1. **Fetch secrets**: Call your proprietary API, database, or vault:
214+
215+
```typescript
216+
const secret = await fetchFromYourSecretStore(secretName, environment);
217+
```
218+
219+
1. **Return secrets**: Format the response as a JSON object:
220+
221+
```typescript
222+
return {
223+
statusCode: 200,
224+
headers: { "Content-Type": "application/json" },
225+
body: JSON.stringify({
226+
username: secret.username,
227+
password: secret.password,
228+
endpoint: secret.endpoint,
229+
}),
230+
};
231+
```
232+
233+
1. **Handle errors**: Return appropriate HTTP status codes:
234+
- `200`: Success
235+
- `401`: Authentication failure
236+
- `400`: Validation failure or bad request
237+
- `404`: Secret not found
238+
- `500`: Internal error
239+
240+
### Example modification for a real secret source
241+
242+
Here's how you might modify the handler to fetch from an internal API:
243+
244+
```typescript
245+
// After JWT validation succeeds...
246+
const { secretPath } = request;
247+
248+
try {
249+
// Call your internal API
250+
const response = await fetch(`https://internal.example.com/secrets/${secretPath}`, {
251+
headers: {
252+
"Authorization": `Bearer ${process.env.INTERNAL_API_TOKEN}`,
253+
},
254+
});
255+
256+
if (!response.ok) {
257+
return {
258+
statusCode: response.status,
259+
body: JSON.stringify({ error: "Failed to fetch secret" }),
260+
};
261+
}
262+
263+
const secret = await response.json();
264+
265+
return {
266+
statusCode: 200,
267+
headers: { "Content-Type": "application/json" },
268+
body: JSON.stringify({
269+
apiKey: secret.api_key,
270+
apiSecret: secret.api_secret,
271+
}),
272+
};
273+
} catch (error) {
274+
console.error("Error fetching secret:", error);
275+
return {
276+
statusCode: 500,
277+
body: JSON.stringify({ error: "Internal server error" }),
278+
};
279+
}
280+
```
281+
282+
## Using with external rotator
283+
284+
The external rotator works similarly to the external provider, but is designed for credential rotation. Here's a brief example:
285+
286+
```yaml
287+
values:
288+
rotatedCreds:
289+
fn::rotate::external:
290+
inputs:
291+
url: https://YOUR-API-ID.execute-api.us-west-2.amazonaws.com/stage/
292+
request:
293+
service: my-api
294+
```
295+
296+
Your rotator adapter receives:
297+
298+
```json
299+
{
300+
"request": { "service": "my-api" },
301+
"state": null
302+
}
303+
```
304+
305+
On the first rotation, `state` is `null`. On subsequent rotations, `state` contains the previous response. Your adapter should generate new credentials and return them:
306+
307+
```json
308+
{
309+
"apiKey": "newly-generated-key-123",
310+
"rotatedAt": "2025-11-26T12:00:00Z"
311+
}
312+
```
313+
314+
ESC stores this as the `current` state and passes it as `state` on the next rotation, allowing you to implement a two-secret rotation strategy for zero-downtime credential updates.
315+
316+
See the [external rotator documentation](/docs/esc/integrations/rotated-secrets/external/) for details.
317+
318+
## Clean up
319+
320+
Remove all deployed resources:
321+
322+
```bash
323+
pulumi destroy
324+
```
325+
326+
Delete the stack:
327+
328+
```bash
329+
pulumi stack rm dev
330+
```
331+
332+
## Additional resources
333+
334+
- [External Provider Documentation](/docs/esc/integrations/dynamic-secrets/external/)
335+
- [External Rotator Documentation](/docs/esc/integrations/rotated-secrets/external/)
336+
- [Pulumi ESC Documentation](/docs/esc/)
337+
- [Blog Post: Announcing External Provider and Rotator](/blog/esc-external-provider-rotator-launch/)
338+
- [Pulumi Community Slack](https://slack.pulumi.com/)
339+
- [ESC GitHub Issues](https://github.com/pulumi/esc/issues)

0 commit comments

Comments
 (0)