diff --git a/package-lock.json b/package-lock.json index dcd114a66..e26f506a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2299,6 +2299,12 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "dev": true }, + "@types/aws-lambda": { + "version": "8.10.73", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.73.tgz", + "integrity": "sha512-P+a6TRQbRnVQOIjWkmw6F23wiJcF+4Uniasbzx7NAXjLQCVGx/Z4VoMfit81/pxlmcXNxAMGuYPugn6CrJLilQ==", + "dev": true + }, "@types/babel__core": { "version": "7.1.12", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.12.tgz", @@ -2931,6 +2937,29 @@ "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", "dev": true }, + "aws-sdk": { + "version": "2.874.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.874.0.tgz", + "integrity": "sha512-YF2LYIfIuywFTzojGwwUwAiq+pgSM3IWRF/uDoJa28xRQSfYD1uMeZLCqqMt+L0/yxe2UJDYKTVPhB9eEqOxEQ==", + "requires": { + "buffer": "4.9.2", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.3.2", + "xml2js": "0.4.19" + }, + "dependencies": { + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + } + } + }, "aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -3075,6 +3104,11 @@ } } }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -3180,6 +3214,16 @@ "node-int64": "^0.4.0" } }, + "buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -4941,6 +4985,11 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, "exec-sh": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.4.tgz", @@ -6252,6 +6301,11 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, "ignore": { "version": "5.1.8", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz", @@ -6865,8 +6919,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isexe": { "version": "2.0.0", @@ -7549,6 +7602,11 @@ "integrity": "sha1-o6vicYryQaKykE+EpiWXDzia4yo=", "dev": true }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -13403,6 +13461,11 @@ "strict-uri-encode": "^1.0.0" } }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, "quick-lru": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", @@ -14138,6 +14201,11 @@ } } }, + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, "saxes": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", @@ -15932,6 +16000,22 @@ "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", "dev": true }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + } + } + }, "url-join": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", @@ -16216,6 +16300,20 @@ "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "dev": true }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + }, "xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 219627663..df61ca6e6 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@guardian/eslint-config-typescript": "^0.5.0", + "@types/aws-lambda": "^8.10.72", "@types/jest": "^26.0.22", "@types/node": "14.14.37", "@typescript-eslint/eslint-plugin": "^4.19.0", @@ -60,6 +61,7 @@ "@aws-cdk/aws-rds": "1.94.1", "@aws-cdk/aws-s3": "1.94.1", "@aws-cdk/core": "1.94.1", + "aws-sdk": "^2.868.0", "read-pkg-up": "^8.0.0" }, "config": { diff --git a/src/constructs/core/custom-resources/interfaces.ts b/src/constructs/core/custom-resources/interfaces.ts new file mode 100644 index 000000000..09e0db1ed --- /dev/null +++ b/src/constructs/core/custom-resources/interfaces.ts @@ -0,0 +1,5 @@ +import type { GetParameterRequest } from "aws-sdk/clients/ssm"; + +export interface CustomResourceGetParameterProps { + apiRequest: GetParameterRequest; +} diff --git a/src/constructs/core/custom-resources/runtime/lambda.js b/src/constructs/core/custom-resources/runtime/lambda.js new file mode 100644 index 000000000..7f53b8450 --- /dev/null +++ b/src/constructs/core/custom-resources/runtime/lambda.js @@ -0,0 +1,4 @@ +/* +* The reason for this file is to appease the tests in `core/ssm.test.ts`. +* We don't need this to contain anything useful because we're not testing the contents of it within that testing suite. +* */ diff --git a/src/constructs/core/custom-resources/runtime/lambda.symlink.ts b/src/constructs/core/custom-resources/runtime/lambda.symlink.ts new file mode 120000 index 000000000..71e3e015f --- /dev/null +++ b/src/constructs/core/custom-resources/runtime/lambda.symlink.ts @@ -0,0 +1 @@ +../runtime/lambda.ts \ No newline at end of file diff --git a/src/constructs/core/custom-resources/runtime/lambda.test.ts b/src/constructs/core/custom-resources/runtime/lambda.test.ts new file mode 100644 index 000000000..cc134d1f9 --- /dev/null +++ b/src/constructs/core/custom-resources/runtime/lambda.test.ts @@ -0,0 +1,17 @@ +/* + * You can't specify the file extension (eg .ts) of the file to import, + * and lambda.js exists as a dummy file, so we'd always import that over lambda.ts (the actual code to be tested) if + * we imported `lambda` + * lambda.symlink is a symlink to lambda.ts, so we can test it without importing lambda.js + * */ +import { flatten } from "./lambda.symlink"; + +describe("The flatten function", function () { + it("should flatten nested objects into one", function () { + expect(flatten({ foo: { bar: "baz" } })).toEqual({ "foo.bar": "baz" }); + }); + + it("should flatten infinitely nested objects into one", function () { + expect(flatten({ foo: { bar: { baz: { blah: "foo" } } } })).toEqual({ "foo.bar.baz.blah": "foo" }); + }); +}); diff --git a/src/constructs/core/custom-resources/runtime/lambda.ts b/src/constructs/core/custom-resources/runtime/lambda.ts new file mode 100644 index 000000000..1824c3b19 --- /dev/null +++ b/src/constructs/core/custom-resources/runtime/lambda.ts @@ -0,0 +1,102 @@ +import { request } from "https"; +import { parse } from "url"; +// eslint-disable-next-line import/no-unresolved -- this comes from @types/aws-lambda, but eslint can't seem to read it properly +import type { CloudFormationCustomResourceEvent, Context } from "aws-lambda"; +import SSM from "aws-sdk/clients/ssm"; +import type { CustomResourceGetParameterProps } from "../interfaces"; + +/* eslint-disable -- This function is copied straight from AWS */ +// https://github.com/aws/aws-cdk/blob/95438b56bfdc90e94f969f6998e5b5b680cbd7a8/packages/%40aws-cdk/custom-resources/lib/aws-custom-resource/runtime/index.ts#L16-L29 +export function flatten(object: Record): Record { + return Object.assign( + {}, + ...(function _flatten(child: Record, path: string[] = []): any { + return [].concat( + ...Object.keys(child).map((key) => { + const childKey = Buffer.isBuffer(child[key]) ? child[key].toString() : child[key]; + return typeof childKey === "object" + ? _flatten(childKey, path.concat([key])) + : { [path.concat([key]).join(".")]: childKey }; + }) + ); + })(object) + ) as Record; +} +/* eslint-enable */ + +export async function handler(event: CloudFormationCustomResourceEvent, context: Context): Promise { + try { + console.log(JSON.stringify(event)); + + // Default physical resource id + let physicalResourceId: string; + switch (event.RequestType) { + case "Create": + physicalResourceId = event.LogicalResourceId; + break; + case "Update": + physicalResourceId = event.PhysicalResourceId; + break; + case "Delete": + await respond("SUCCESS", "OK", event.PhysicalResourceId, {}); + return; + } + + let data: Record = {}; + + const getParamsProps = event.ResourceProperties.getParamsProps as string | undefined; + + if (getParamsProps) { + const request = decodeCall(getParamsProps); + const ssmClient = new SSM(); + const response = await ssmClient.getParameter(request.apiRequest).promise(); + console.log("Response:", JSON.stringify(response, null, 4)); + data = { ...flatten((response as unknown) as Record) }; + } + + console.log("data: ", data); + await respond("SUCCESS", "OK", physicalResourceId, data); + } catch (e) { + console.log(e); + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/message + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- e.message is valid, see docs above + await respond("FAILED", e.message || "Internal Error", context.logStreamName, {}); + } + + function respond(responseStatus: string, reason: string, physicalResourceId: string, data: Record) { + const responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reason, + PhysicalResourceId: physicalResourceId, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + NoEcho: false, + Data: data, + }); + + console.log("Responding", responseBody); + const parsedUrl = parse(event.ResponseURL); + const requestOptions = { + hostname: parsedUrl.hostname, + path: parsedUrl.path, + method: "PUT", + headers: { "content-type": "", "content-length": responseBody.length }, + }; + + return new Promise((resolve, reject) => { + try { + const r = request(requestOptions, resolve); + r.on("error", reject); + r.write(responseBody); + r.end(); + } catch (e) { + reject(e); + } + }); + } +} + +function decodeCall(call: string): CustomResourceGetParameterProps { + return JSON.parse(call) as CustomResourceGetParameterProps; +} diff --git a/src/constructs/core/ssm.test.ts b/src/constructs/core/ssm.test.ts new file mode 100644 index 000000000..d52bd987c --- /dev/null +++ b/src/constructs/core/ssm.test.ts @@ -0,0 +1,58 @@ +import "@aws-cdk/assert/jest"; +import { simpleGuStackForTesting } from "../../../test/utils"; +import { GuSSMIdentityParameter, GuSSMParameter, id } from "./ssm"; + +describe("SSM:", () => { + describe("The GuSSMIdentityParameter construct", () => { + it("requires the scope and parameter name", function () { + const stack = simpleGuStackForTesting({ stack: "some-stack" }); + const param = new GuSSMIdentityParameter(stack, { parameter: "some-param", app: "foo" }); + expect(stack).toHaveResourceLike("Custom::GuGetSSMParameter", { + getParamsProps: { + "Fn::Join": ["", ['{"apiRequest":{"Name":"/', { Ref: "Stage" }, '/some-stack/foo/some-param"}}']], + }, + }); + expect(param.getValue()).toMatch(/TOKEN/i); + }); + }); + + describe("The GuSSMParameter construct", () => { + it("requires the scope and parameter name", function () { + const stack = simpleGuStackForTesting({ stack: "some-stack" }); + const param = new GuSSMParameter(stack, { parameter: "/path/to/some-param" }); + expect(stack).toHaveResourceLike("Custom::GuGetSSMParameter", { + getParamsProps: '{"apiRequest":{"Name":"/path/to/some-param"}}', + }); + expect(param.getValue()).toMatch(/TOKEN/i); + }); + + it("creates unique IDs using param and stack name for the parameters", function () { + const stack = simpleGuStackForTesting({ stack: "some-stack" }); + const param1 = new GuSSMParameter(stack, { parameter: "/path/to/some-param" }); + const param2 = new GuSSMParameter(stack, { parameter: "/path/to/some-param" }); + + expect(param1.toString()).toMatch(/Test\/GuSSMParameter-pathtosomeparam/i); + expect(param2.toString()).toMatch(/Test\/GuSSMParameter-pathtosomeparam/i); + expect(param1.toString()).not.toEqual(param2.toString()); + }); + + it("creates unique IDs that handles tokens", function () { + const stack = simpleGuStackForTesting({ stack: "some-stack" }); + const param1 = new GuSSMParameter(stack, { parameter: `/path/${stack.stage}/some-param` }); + const param2 = new GuSSMParameter(stack, { parameter: `/path/${stack.stage}/some-param` }); + expect(param1.toString()).toMatch(/Test\/GuSSMParameter-token/i); + expect(param2.toString()).toMatch(/Test\/GuSSMParameter-token/i); + expect(param1.toString()).not.toEqual(param2.toString()); + }); + }); + + describe("the id function", function () { + it("creates a unique ID given a string", function () { + expect(id("NameOfConstruct", "some-parameter")).toMatch(/NameOfConstruct-someparameter/i); + }); + + it("will substitute a CDK token for an acceptable string", function () { + expect(id("NameOfConstruct", "${TOKEN}foobar")).toMatch(/NameOfConstruct-token/i); + }); + }); +}); diff --git a/src/constructs/core/ssm.ts b/src/constructs/core/ssm.ts new file mode 100644 index 000000000..388d533d0 --- /dev/null +++ b/src/constructs/core/ssm.ts @@ -0,0 +1,94 @@ +import { readFileSync } from "fs"; +import { join } from "path"; +import type { IGrantable, IPrincipal } from "@aws-cdk/aws-iam"; +import { Policy, PolicyStatement } from "@aws-cdk/aws-iam"; +import { Code, Runtime, SingletonFunction } from "@aws-cdk/aws-lambda"; +import type { Reference } from "@aws-cdk/core"; +import { Construct, CustomResource, Duration } from "@aws-cdk/core"; +import { AwsCustomResourcePolicy } from "@aws-cdk/custom-resources"; +import type { CustomResourceGetParameterProps } from "./custom-resources/interfaces"; +import type { AppIdentity } from "./identity"; +import type { GuStack } from "./stack"; + +export interface GuSSMParameterProps { + parameter: string; + secure?: boolean; +} + +export interface GuSSMIdentityParameterProps extends GuSSMParameterProps, AppIdentity {} + +const stripped = (str: string) => str.replace(/[-/]/g, ""); + +export function id(id: string, parameter: string): string { + const now = Date.now(); + // We need to create UIDs for the resources in this construct, as otherwise CFN will not trigger the lambda on updates for resources that appear to be the same + const uid = now.toString().substr(now.toString().length - 4); + return parameter.toUpperCase().includes("TOKEN") ? `${id}-token-${uid}` : `${id}-${stripped(parameter)}-${uid}`; +} + +export class GuSSMParameter extends Construct implements IGrantable { + private readonly customResource: CustomResource; + readonly grantPrincipal: IPrincipal; + + constructor(scope: GuStack, props: GuSSMParameterProps) { + super(scope, id("GuSSMParameter", props.parameter)); + const { parameter } = props; + + const provider = new SingletonFunction(scope, id("Provider", parameter), { + code: Code.fromInline(readFileSync(join(__dirname, "/custom-resources/runtime/lambda.js")).toString()), + runtime: Runtime.NODEJS_12_X, + handler: "index.handler", + uuid: "eda001a3-b7c8-469d-bc13-787c4e13cfd9", + lambdaPurpose: "Lambda to fetch SSM parameters", + timeout: Duration.minutes(2), + }); + + this.grantPrincipal = provider.grantPrincipal; + + const policy = new Policy(scope, id("CustomResourcePolicy", parameter), { + statements: [ + new PolicyStatement({ + actions: ["ssm:getParameter"], + resources: AwsCustomResourcePolicy.ANY_RESOURCE, // This feels too permissive, but possibly okay + }), + ], + }); + + if (provider.role !== undefined) { + policy.attachToRole(provider.role); + } + + const getParamsProps: CustomResourceGetParameterProps = { + apiRequest: { Name: parameter, WithDecryption: props.secure }, + }; + + this.customResource = new CustomResource(this, id("Resource", parameter), { + resourceType: "Custom::GuGetSSMParameter", + serviceToken: provider.functionArn, + pascalCaseProperties: false, + properties: { getParamsProps: JSON.stringify(getParamsProps) }, + }); + + // If the policy was deleted first, then the function might lose permissions to delete the custom resource + // This is here so that the policy doesn't get removed before onDelete is called + this.customResource.node.addDependency(policy); + } + + public getValueReference(): Reference { + return this.customResource.getAtt("Parameter.Value"); + } + + public getValue(): string { + return this.customResource.getAttString("Parameter.Value"); + } +} + +/* + * Assumes the path of `/${STAGE}/${STACK}/${APP}/${parameter}` + * + * */ +export class GuSSMIdentityParameter extends GuSSMParameter { + constructor(scope: GuStack, props: GuSSMIdentityParameterProps) { + super(scope, { ...props, parameter: `/${scope.stage}/${scope.stack}/${props.app}/${props.parameter}` }); + } +}