Skip to content

Commit 22be8bb

Browse files
nyobeclaude
andauthored
[ESC] Example external adapter (#2213)
This is a deployable reference implementation of an adapter service that the ESC external provider can interact with. It will be linked from the docs: pulumi/docs#16589 --------- Co-authored-by: Claude <[email protected]>
1 parent b74c41c commit 22be8bb

File tree

6 files changed

+352
-0
lines changed

6 files changed

+352
-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-esc-external-adapter-lambda
2+
runtime: nodejs
3+
description: External secrets adapter for Pulumi ESC using AWS Lambda with JWT authentication
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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-esc-external-adapter-lambda/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-esc-external-adapter-lambda/README.md#gh-dark-mode-only)
3+
4+
# External secrets adapter for Pulumi ESC on AWS Lambda
5+
6+
A reference implementation showing how to build a secure external secrets adapter for Pulumi ESC.
7+
This example validates JWT authentication and request integrity, making it easy to integrate custom or proprietary secret sources with ESC.
8+
9+
For complete documentation on ESC Connect, see the [external provider documentation](/docs/esc/integrations/dynamic-secrets/external/).
10+
11+
## Deploying the adapter
12+
13+
1. Install dependencies:
14+
15+
```bash
16+
npm install
17+
```
18+
19+
1. Create a new Pulumi stack:
20+
21+
```bash
22+
pulumi stack init dev
23+
```
24+
25+
1. Configure your AWS region:
26+
27+
```bash
28+
pulumi config set aws:region us-west-2
29+
```
30+
31+
1. Deploy:
32+
33+
```bash
34+
pulumi up
35+
```
36+
37+
1. Copy the adapter URL from the output:
38+
39+
```bash
40+
export ADAPTER_URL=$(pulumi stack output adapterUrl)
41+
```
42+
43+
## Using with Pulumi ESC
44+
45+
Create a Pulumi ESC environment:
46+
47+
```yaml
48+
values:
49+
demo:
50+
fn::open::external:
51+
url: https://YOUR-API-ID.execute-api.us-west-2.amazonaws.com/stage/
52+
request:
53+
message: "Hello from ESC!"
54+
```
55+
56+
Open the environment:
57+
58+
```bash
59+
esc open <your-org>/external-demo
60+
```
61+
62+
Expected output:
63+
64+
```json
65+
{
66+
"demo": {
67+
"response": {
68+
"message": "External secrets adapter responding successfully!",
69+
"requestEcho": {
70+
"message": "Hello from ESC!"
71+
},
72+
"timestamp": "2025-11-26T12:00:00.000Z"
73+
}
74+
}
75+
}
76+
```
77+
78+
## Building your own adapter
79+
80+
The `ESCRequestValidator` class in `index.ts` handles request integrity validation. To integrate your own secret source:
81+
82+
1. Copy the `ESCRequestValidator` class into your adapter
83+
1. Replace the `TODO` comment in the Lambda handler with your secret fetching logic:
84+
85+
```typescript
86+
const { claims, requestBody } = await validator.validateRequest(event);
87+
88+
// Use claims to further authorize the request
89+
if (claims.org !== "YOUR-PULUMI-ORG") {
90+
return { statusCode: 401 };
91+
}
92+
93+
// Fetch from your secret source
94+
const secret = await fetchFromYourSecretStore(requestBody.secretName);
95+
96+
return {
97+
statusCode: 200,
98+
body: JSON.stringify(secret),
99+
};
100+
```
101+
102+
See the [external provider documentation](/docs/esc/integrations/dynamic-secrets/external/) for complete implementation guidance and examples in other languages.
103+
104+
## Monitoring
105+
106+
View Lambda logs:
107+
108+
```bash
109+
pulumi logs --follow
110+
```
111+
112+
Or use the AWS CLI:
113+
114+
```bash
115+
aws logs tail /aws/lambda/$(pulumi stack output functionName) --follow
116+
```
117+
118+
The handler logs JWT claims to CloudWatch for debugging.
119+
120+
## Clean up
121+
122+
```bash
123+
pulumi destroy
124+
pulumi stack rm dev
125+
```
126+
127+
## Additional resources
128+
129+
- [Blog: Introducing ESC Connect](https://www.pulumi.com/blog/esc-connect/)
130+
- [External Provider Documentation](https://www.pulumi.com/docs/esc/integrations/dynamic-secrets/external/)
131+
- [External Rotator Documentation](https://www.pulumi.com/docs/esc/integrations/rotated-secrets/external/)
132+
- [Pulumi ESC Documentation](https://www.pulumi.com/docs/esc/)
133+
- [Pulumi Community Slack](https://slack.pulumi.com/)
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright 2016-2025, Pulumi Corporation. All rights reserved.
2+
3+
import * as aws from "@pulumi/aws";
4+
import * as apigateway from "@pulumi/aws-apigateway";
5+
import * as pulumi from "@pulumi/pulumi";
6+
7+
import * as crypto from "crypto";
8+
import * as jwt from "jsonwebtoken";
9+
import * as jwksClient from "jwks-rsa";
10+
11+
interface APIGatewayProxyEvent {
12+
headers: { [key: string]: string | undefined };
13+
requestContext: {
14+
domainName: string;
15+
path: string;
16+
};
17+
body: string | null;
18+
}
19+
20+
interface APIGatewayProxyResult {
21+
statusCode: number;
22+
headers?: { [key: string]: string };
23+
body: string;
24+
}
25+
26+
/**
27+
* Reusable helper for validating ESC external provider requests.
28+
* Copy-paste this into your own adapters to get secure JWT validation.
29+
*/
30+
class ESCRequestValidator {
31+
private client: jwksClient.JwksClient;
32+
33+
constructor(jwksUrl: string = "https://api.pulumi.com/oidc/.well-known/jwks") {
34+
this.client = jwksClient({
35+
jwksUri: jwksUrl,
36+
cache: true,
37+
cacheMaxAge: 600000, // 10 minutes
38+
});
39+
}
40+
41+
/**
42+
* Validates an ESC external provider request.
43+
* Returns the validated JWT claims and request body on success.
44+
* Throws an error with a user-friendly message on validation failure.
45+
*/
46+
async validateRequest(event: APIGatewayProxyEvent): Promise<{
47+
claims: jwt.JwtPayload;
48+
requestBody: any;
49+
}> {
50+
// Extract Authorization header
51+
const authHeader = event.headers.Authorization || event.headers.authorization;
52+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
53+
throw new Error("Missing or invalid Authorization header");
54+
}
55+
56+
const token = authHeader.substring(7);
57+
const requestBody = event.body || "{}";
58+
59+
// Verify JWT signature and claims
60+
const decoded = await this.verifyJWT(token);
61+
62+
// Verify audience matches adapter URL
63+
const requestUrl = `https://${event.headers.Host || event.requestContext.domainName}${event.requestContext.path}`;
64+
if (decoded.aud !== requestUrl) {
65+
throw new Error(`Audience mismatch: expected ${requestUrl}, got ${decoded.aud}`);
66+
}
67+
68+
// Verify body hash
69+
const bodyHash = decoded.body_hash as string;
70+
if (!bodyHash) {
71+
throw new Error("Missing body_hash claim in JWT");
72+
}
73+
74+
if (!this.verifyBodyHash(requestBody, bodyHash)) {
75+
throw new Error("Body hash verification failed");
76+
}
77+
78+
return {
79+
claims: decoded,
80+
requestBody: JSON.parse(requestBody),
81+
};
82+
}
83+
84+
private async verifyJWT(token: string): Promise<jwt.JwtPayload> {
85+
return new Promise((resolve, reject) => {
86+
jwt.verify(
87+
token,
88+
(header, callback) => {
89+
this.client.getSigningKey(header.kid, (err, key) => {
90+
if (err) {
91+
callback(err);
92+
return;
93+
}
94+
callback(null, key?.getPublicKey());
95+
});
96+
},
97+
{
98+
algorithms: ["RS256"],
99+
complete: false,
100+
},
101+
(err, decoded) => {
102+
if (err) { reject(err); }
103+
else { resolve(decoded as jwt.JwtPayload); }
104+
},
105+
);
106+
});
107+
}
108+
109+
private verifyBodyHash(body: string, expectedHash: string): boolean {
110+
const hash = crypto.createHash("sha256").update(body).digest("base64");
111+
const actualHash = `sha256-${hash}`;
112+
return actualHash === expectedHash;
113+
}
114+
}
115+
116+
const adapterFunction = new aws.lambda.CallbackFunction("escExternalAdapter", {
117+
callback: async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
118+
try {
119+
// Initialize the validator (Lambda will cache across invocations)
120+
const validator = new ESCRequestValidator();
121+
122+
// Validate the request using the reusable helper
123+
const { claims, requestBody } = await validator.validateRequest(event);
124+
125+
// Log JWT claims for debugging. You can use these claims for authorization decisions.
126+
console.log("JWT validation successful");
127+
console.log("Organization:", claims.org);
128+
console.log("Environment:", claims.current_env);
129+
console.log("Trigger User:", claims.trigger_user);
130+
console.log("Issued At:", new Date((claims.iat || 0) * 1000).toISOString());
131+
132+
// TODO: Replace this with your secret fetching logic
133+
// For example:
134+
// const secret = await fetchFromYourSecretStore(requestBody.secretName);
135+
// return { statusCode: 200, body: JSON.stringify(secret) };
136+
137+
return {
138+
statusCode: 200,
139+
headers: {
140+
"Content-Type": "application/json",
141+
},
142+
body: JSON.stringify({
143+
message: "External secrets adapter responding successfully!",
144+
requestEcho: requestBody,
145+
timestamp: new Date().toISOString(),
146+
}),
147+
};
148+
} catch (error: any) {
149+
console.error("Error processing request:", error);
150+
151+
// Return appropriate status code based on error type
152+
const statusCode = error.message.includes("Authorization") ? 401
153+
: error.message.includes("hash") || error.message.includes("Audience") ? 400
154+
: 500;
155+
156+
return {
157+
statusCode,
158+
body: JSON.stringify({
159+
error: error.message || "Internal server error",
160+
}),
161+
};
162+
}
163+
},
164+
policies: [aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole],
165+
});
166+
167+
const api = new apigateway.RestAPI("escExternalAdapterApi", {
168+
routes: [{
169+
path: "/",
170+
method: "POST",
171+
eventHandler: adapterFunction,
172+
}],
173+
// Don't treat JSON as binary data
174+
binaryMediaTypes: [],
175+
});
176+
177+
export const adapterUrl = api.url;
178+
export const functionName = adapterFunction.name;
179+
export const functionArn = adapterFunction.arn;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "aws-ts-lambda-external-secrets",
3+
"version": "1.0.0",
4+
"description": "External secrets adapter for Pulumi ESC using AWS Lambda",
5+
"main": "index.ts",
6+
"devDependencies": {
7+
"@types/node": "^22.0.0",
8+
"typescript": "^5.0.0"
9+
},
10+
"dependencies": {
11+
"@pulumi/pulumi": "^3.100.0",
12+
"@pulumi/aws": "^6.0.0",
13+
"@pulumi/aws-apigateway": "^2.0.0",
14+
"jsonwebtoken": "^9.0.0",
15+
"jwks-rsa": "^3.0.0"
16+
}
17+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"compilerOptions": {
3+
"strict": true,
4+
"outDir": "bin",
5+
"target": "es2016",
6+
"module": "commonjs",
7+
"moduleResolution": "node",
8+
"sourceMap": true,
9+
"experimentalDecorators": true,
10+
"pretty": true,
11+
"noFallthroughCasesInSwitch": true,
12+
"noImplicitReturns": true,
13+
"forceConsistentCasingInFileNames": true
14+
},
15+
"files": [
16+
"index.ts"
17+
]
18+
}

0 commit comments

Comments
 (0)