diff --git a/aws-ts-esc-external-adapter-lambda/.gitignore b/aws-ts-esc-external-adapter-lambda/.gitignore new file mode 100644 index 000000000..c6958891d --- /dev/null +++ b/aws-ts-esc-external-adapter-lambda/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/node_modules/ diff --git a/aws-ts-esc-external-adapter-lambda/Pulumi.yaml b/aws-ts-esc-external-adapter-lambda/Pulumi.yaml new file mode 100644 index 000000000..6b7ea0fcb --- /dev/null +++ b/aws-ts-esc-external-adapter-lambda/Pulumi.yaml @@ -0,0 +1,3 @@ +name: aws-ts-esc-external-adapter-lambda +runtime: nodejs +description: External secrets adapter for Pulumi ESC using AWS Lambda with JWT authentication diff --git a/aws-ts-esc-external-adapter-lambda/README.md b/aws-ts-esc-external-adapter-lambda/README.md new file mode 100644 index 000000000..3903133ed --- /dev/null +++ b/aws-ts-esc-external-adapter-lambda/README.md @@ -0,0 +1,133 @@ +[![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) +[![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) + +# External secrets adapter for Pulumi ESC on AWS Lambda + +A reference implementation showing how to build a secure external secrets adapter for Pulumi ESC. +This example validates JWT authentication and request integrity, making it easy to integrate custom or proprietary secret sources with ESC. + +For complete documentation on ESC Connect, see the [external provider documentation](/docs/esc/integrations/dynamic-secrets/external/). + +## Deploying the adapter + +1. Install dependencies: + + ```bash + npm install + ``` + +1. Create a new Pulumi stack: + + ```bash + pulumi stack init dev + ``` + +1. Configure your AWS region: + + ```bash + pulumi config set aws:region us-west-2 + ``` + +1. Deploy: + + ```bash + pulumi up + ``` + +1. Copy the adapter URL from the output: + + ```bash + export ADAPTER_URL=$(pulumi stack output adapterUrl) + ``` + +## Using with Pulumi ESC + +Create a Pulumi ESC environment: + +```yaml +values: + demo: + fn::open::external: + url: https://YOUR-API-ID.execute-api.us-west-2.amazonaws.com/stage/ + request: + message: "Hello from ESC!" +``` + +Open the environment: + +```bash +esc open /external-demo +``` + +Expected output: + +```json +{ + "demo": { + "response": { + "message": "External secrets adapter responding successfully!", + "requestEcho": { + "message": "Hello from ESC!" + }, + "timestamp": "2025-11-26T12:00:00.000Z" + } + } +} +``` + +## Building your own adapter + +The `ESCRequestValidator` class in `index.ts` handles request integrity validation. To integrate your own secret source: + +1. Copy the `ESCRequestValidator` class into your adapter +1. Replace the `TODO` comment in the Lambda handler with your secret fetching logic: + + ```typescript + const { claims, requestBody } = await validator.validateRequest(event); + + // Use claims to further authorize the request + if (claims.org !== "YOUR-PULUMI-ORG") { + return { statusCode: 401 }; + } + + // Fetch from your secret source + const secret = await fetchFromYourSecretStore(requestBody.secretName); + + return { + statusCode: 200, + body: JSON.stringify(secret), + }; + ``` + +See the [external provider documentation](/docs/esc/integrations/dynamic-secrets/external/) for complete implementation guidance and examples in other languages. + +## Monitoring + +View Lambda logs: + +```bash +pulumi logs --follow +``` + +Or use the AWS CLI: + +```bash +aws logs tail /aws/lambda/$(pulumi stack output functionName) --follow +``` + +The handler logs JWT claims to CloudWatch for debugging. + +## Clean up + +```bash +pulumi destroy +pulumi stack rm dev +``` + +## Additional resources + +- [Blog: Introducing ESC Connect](https://www.pulumi.com/blog/esc-connect/) +- [External Provider Documentation](https://www.pulumi.com/docs/esc/integrations/dynamic-secrets/external/) +- [External Rotator Documentation](https://www.pulumi.com/docs/esc/integrations/rotated-secrets/external/) +- [Pulumi ESC Documentation](https://www.pulumi.com/docs/esc/) +- [Pulumi Community Slack](https://slack.pulumi.com/) diff --git a/aws-ts-esc-external-adapter-lambda/index.ts b/aws-ts-esc-external-adapter-lambda/index.ts new file mode 100644 index 000000000..6e0046bcb --- /dev/null +++ b/aws-ts-esc-external-adapter-lambda/index.ts @@ -0,0 +1,179 @@ +// Copyright 2016-2025, Pulumi Corporation. All rights reserved. + +import * as aws from "@pulumi/aws"; +import * as apigateway from "@pulumi/aws-apigateway"; +import * as pulumi from "@pulumi/pulumi"; + +import * as crypto from "crypto"; +import * as jwt from "jsonwebtoken"; +import * as jwksClient from "jwks-rsa"; + +interface APIGatewayProxyEvent { + headers: { [key: string]: string | undefined }; + requestContext: { + domainName: string; + path: string; + }; + body: string | null; +} + +interface APIGatewayProxyResult { + statusCode: number; + headers?: { [key: string]: string }; + body: string; +} + +/** + * Reusable helper for validating ESC external provider requests. + * Copy-paste this into your own adapters to get secure JWT validation. + */ +class ESCRequestValidator { + private client: jwksClient.JwksClient; + + constructor(jwksUrl: string = "https://api.pulumi.com/oidc/.well-known/jwks") { + this.client = jwksClient({ + jwksUri: jwksUrl, + cache: true, + cacheMaxAge: 600000, // 10 minutes + }); + } + + /** + * Validates an ESC external provider request. + * Returns the validated JWT claims and request body on success. + * Throws an error with a user-friendly message on validation failure. + */ + async validateRequest(event: APIGatewayProxyEvent): Promise<{ + claims: jwt.JwtPayload; + requestBody: any; + }> { + // Extract Authorization header + const authHeader = event.headers.Authorization || event.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + throw new Error("Missing or invalid Authorization header"); + } + + const token = authHeader.substring(7); + const requestBody = event.body || "{}"; + + // Verify JWT signature and claims + const decoded = await this.verifyJWT(token); + + // Verify audience matches adapter URL + const requestUrl = `https://${event.headers.Host || event.requestContext.domainName}${event.requestContext.path}`; + if (decoded.aud !== requestUrl) { + throw new Error(`Audience mismatch: expected ${requestUrl}, got ${decoded.aud}`); + } + + // Verify body hash + const bodyHash = decoded.body_hash as string; + if (!bodyHash) { + throw new Error("Missing body_hash claim in JWT"); + } + + if (!this.verifyBodyHash(requestBody, bodyHash)) { + throw new Error("Body hash verification failed"); + } + + return { + claims: decoded, + requestBody: JSON.parse(requestBody), + }; + } + + private async verifyJWT(token: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify( + token, + (header, callback) => { + this.client.getSigningKey(header.kid, (err, key) => { + if (err) { + callback(err); + return; + } + callback(null, key?.getPublicKey()); + }); + }, + { + algorithms: ["RS256"], + complete: false, + }, + (err, decoded) => { + if (err) { reject(err); } + else { resolve(decoded as jwt.JwtPayload); } + }, + ); + }); + } + + private verifyBodyHash(body: string, expectedHash: string): boolean { + const hash = crypto.createHash("sha256").update(body).digest("base64"); + const actualHash = `sha256-${hash}`; + return actualHash === expectedHash; + } +} + +const adapterFunction = new aws.lambda.CallbackFunction("escExternalAdapter", { + callback: async (event: APIGatewayProxyEvent): Promise => { + try { + // Initialize the validator (Lambda will cache across invocations) + const validator = new ESCRequestValidator(); + + // Validate the request using the reusable helper + const { claims, requestBody } = await validator.validateRequest(event); + + // Log JWT claims for debugging. You can use these claims for authorization decisions. + console.log("JWT validation successful"); + console.log("Organization:", claims.org); + console.log("Environment:", claims.current_env); + console.log("Trigger User:", claims.trigger_user); + console.log("Issued At:", new Date((claims.iat || 0) * 1000).toISOString()); + + // TODO: Replace this with your secret fetching logic + // For example: + // const secret = await fetchFromYourSecretStore(requestBody.secretName); + // return { statusCode: 200, body: JSON.stringify(secret) }; + + return { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message: "External secrets adapter responding successfully!", + requestEcho: requestBody, + timestamp: new Date().toISOString(), + }), + }; + } catch (error: any) { + console.error("Error processing request:", error); + + // Return appropriate status code based on error type + const statusCode = error.message.includes("Authorization") ? 401 + : error.message.includes("hash") || error.message.includes("Audience") ? 400 + : 500; + + return { + statusCode, + body: JSON.stringify({ + error: error.message || "Internal server error", + }), + }; + } + }, + policies: [aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole], +}); + +const api = new apigateway.RestAPI("escExternalAdapterApi", { + routes: [{ + path: "/", + method: "POST", + eventHandler: adapterFunction, + }], + // Don't treat JSON as binary data + binaryMediaTypes: [], +}); + +export const adapterUrl = api.url; +export const functionName = adapterFunction.name; +export const functionArn = adapterFunction.arn; diff --git a/aws-ts-esc-external-adapter-lambda/package.json b/aws-ts-esc-external-adapter-lambda/package.json new file mode 100644 index 000000000..239f47f10 --- /dev/null +++ b/aws-ts-esc-external-adapter-lambda/package.json @@ -0,0 +1,17 @@ +{ + "name": "aws-ts-lambda-external-secrets", + "version": "1.0.0", + "description": "External secrets adapter for Pulumi ESC using AWS Lambda", + "main": "index.ts", + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.0.0" + }, + "dependencies": { + "@pulumi/pulumi": "^3.100.0", + "@pulumi/aws": "^6.0.0", + "@pulumi/aws-apigateway": "^2.0.0", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.0.0" + } +} diff --git a/aws-ts-esc-external-adapter-lambda/tsconfig.json b/aws-ts-esc-external-adapter-lambda/tsconfig.json new file mode 100644 index 000000000..ab65afa61 --- /dev/null +++ b/aws-ts-esc-external-adapter-lambda/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2016", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "files": [ + "index.ts" + ] +}