diff --git a/src/constants/fastly-aws-account-id.ts b/src/constants/fastly-aws-account-id.ts new file mode 100644 index 0000000000..d8eb0bd993 --- /dev/null +++ b/src/constants/fastly-aws-account-id.ts @@ -0,0 +1,7 @@ +/** + * Fastly's AWS account ID. + * This is needed by IAM roles assumed by Fastly in order to write into + * an S3 bucket or a Kinesis stream. + * See https://docs.fastly.com/en/guides/creating-an-aws-iam-role-for-fastly-logging + */ +export const FASTLY_AWS_ACCOUNT_ID = "717331877981"; diff --git a/src/constants/index.ts b/src/constants/index.ts index 8962d1b76a..fdef7be0c0 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -5,3 +5,4 @@ export * from "./regex-pattern"; export * from "./ssm-parameter-paths"; export * from "./metadata-keys"; export * from "./tracking-tag"; +export * from "./fastly-aws-account-id"; diff --git a/src/constructs/iam/fastly-logs-iam.ts b/src/constructs/iam/fastly-logs-iam.ts index cb53497462..8169606bb6 100644 --- a/src/constructs/iam/fastly-logs-iam.ts +++ b/src/constructs/iam/fastly-logs-iam.ts @@ -1,4 +1,5 @@ import { AccountPrincipal } from "aws-cdk-lib/aws-iam"; +import { FASTLY_AWS_ACCOUNT_ID } from "../../constants/fastly-aws-account-id"; import type { GuStack } from "../core"; import { GuFastlyCustomerIdParameter } from "../core"; import { GuPutS3ObjectsPolicy } from "./policies"; @@ -17,10 +18,6 @@ export interface GuFastlyLogsIamRoleProps { path?: string; } -// Fastly's AWS account ID is used as an external ID when creating the IAM role -// See https://docs.fastly.com/en/guides/creating-an-aws-iam-role-for-fastly-logging -const FASTLY_AWS_ACCOUNT_ID = "717331877981"; - /** * Construct which creates the required IAM resources to support Fastly logging to an S3 bucket. * Importantly, it does not create a permanent IAM user, which was once a requirement. diff --git a/src/experimental/constructs/iam/index.ts b/src/experimental/constructs/iam/index.ts new file mode 100644 index 0000000000..70310e7da4 --- /dev/null +++ b/src/experimental/constructs/iam/index.ts @@ -0,0 +1 @@ +export * from "./roles"; diff --git a/src/experimental/constructs/iam/roles/__snapshots__/fastly-kinesis-log.test.ts.snap b/src/experimental/constructs/iam/roles/__snapshots__/fastly-kinesis-log.test.ts.snap new file mode 100644 index 0000000000..47a35ebfbb --- /dev/null +++ b/src/experimental/constructs/iam/roles/__snapshots__/fastly-kinesis-log.test.ts.snap @@ -0,0 +1,170 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The GuFastlyKinesisLogRole construct correctly wires up the policy 1`] = ` +{ + "Conditions": { + "AwsCdkKinesisEncryptedStreamsUnsupportedRegions": { + "Fn::Or": [ + { + "Fn::Equals": [ + { + "Ref": "AWS::Region", + }, + "cn-north-1", + ], + }, + { + "Fn::Equals": [ + { + "Ref": "AWS::Region", + }, + "cn-northwest-1", + ], + }, + ], + }, + }, + "Metadata": { + "gu:cdk:constructs": [ + "GuStack", + "GuKinesisStream", + "GuFastlyCustomerIdParameter", + "GuFastlyKinesisLogRoleExperimental", + "GuKinesisPutRecordsPolicyExperimental", + ], + "gu:cdk:version": "TEST", + }, + "Parameters": { + "FastlyCustomerId": { + "Default": "/account/external/fastly/customer.id", + "Description": "SSM parameter containing the Fastly Customer ID. Can be obtained from https://manage.fastly.com/account/company by an admin", + "Type": "AWS::SSM::Parameter::Value", + }, + }, + "Resources": { + "GuKinesisPutRecordsPolicy28D5CD2D": { + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "kinesis:PutRecords", + "kinesis:ListShards", + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "testStream8BCA7523", + "Arn", + ], + }, + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "GuKinesisPutRecordsPolicy28D5CD2D", + "Roles": [ + { + "Ref": "testKinesisLogRole5E3B33EE", + }, + ], + }, + "Type": "AWS::IAM::Policy", + }, + "testKinesisLogRole5E3B33EE": { + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": { + "Ref": "FastlyCustomerId", + }, + }, + }, + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition", + }, + ":iam::717331877981:root", + ], + ], + }, + }, + }, + ], + "Version": "2012-10-17", + }, + "RoleName": "writeToKinesisRoleTest", + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "testStream8BCA7523": { + "Properties": { + "RetentionPeriodHours": 24, + "ShardCount": 1, + "StreamEncryption": { + "Fn::If": [ + "AwsCdkKinesisEncryptedStreamsUnsupportedRegions", + { + "Ref": "AWS::NoValue", + }, + { + "EncryptionType": "KMS", + "KeyId": "alias/aws/kinesis", + }, + ], + }, + "StreamModeDetails": { + "StreamMode": "PROVISIONED", + }, + "Tags": [ + { + "Key": "gu:cdk:version", + "Value": "TEST", + }, + { + "Key": "gu:repo", + "Value": "guardian/cdk", + }, + { + "Key": "Stack", + "Value": "test-stack", + }, + { + "Key": "Stage", + "Value": "TEST", + }, + ], + }, + "Type": "AWS::Kinesis::Stream", + }, + }, +} +`; diff --git a/src/experimental/constructs/iam/roles/fastly-kinesis-log.test.ts b/src/experimental/constructs/iam/roles/fastly-kinesis-log.test.ts new file mode 100644 index 0000000000..13623c62c2 --- /dev/null +++ b/src/experimental/constructs/iam/roles/fastly-kinesis-log.test.ts @@ -0,0 +1,59 @@ +import { Template } from "aws-cdk-lib/assertions"; +import { FASTLY_AWS_ACCOUNT_ID } from "../../../../constants"; +import { GuKinesisStream } from "../../../../constructs/kinesis"; +import { simpleGuStackForTesting } from "../../../../utils/test"; +import { GuFastlyKinesisLogRoleExperimental } from "./fastly-kinesis-log"; + +describe("The GuFastlyKinesisLogRole construct", () => { + it("correctly wires up the policy", () => { + const stack = simpleGuStackForTesting(); + const testStream = new GuKinesisStream(stack, "testStream"); + new GuFastlyKinesisLogRoleExperimental(stack, "testKinesisLogRole", { + stream: testStream, + roleName: "writeToKinesisRoleTest", + }); + expect(Template.fromStack(stack).toJSON()).toMatchSnapshot(); + }); + + it("assumes the correct role", () => { + const stack = simpleGuStackForTesting(); + const testStream = new GuKinesisStream(stack, "testStream"); + new GuFastlyKinesisLogRoleExperimental(stack, "testKinesisLogRole", { + stream: testStream, + roleName: "writeToKinesisRoleTest", + }); + + Template.fromStack(stack).hasResourceProperties("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Action: "sts:AssumeRole", + Condition: { + StringEquals: { + "sts:ExternalId": { + Ref: "FastlyCustomerId", + }, + }, + }, + Effect: "Allow", + Principal: { + AWS: { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition", + }, + `:iam::${FASTLY_AWS_ACCOUNT_ID}:root`, + ], + ], + }, + }, + }, + ], + }, + }); + }); +}); diff --git a/src/experimental/constructs/iam/roles/fastly-kinesis-log.ts b/src/experimental/constructs/iam/roles/fastly-kinesis-log.ts new file mode 100644 index 0000000000..0327fe3aea --- /dev/null +++ b/src/experimental/constructs/iam/roles/fastly-kinesis-log.ts @@ -0,0 +1,46 @@ +import { AccountPrincipal } from "aws-cdk-lib/aws-iam"; +import { FASTLY_AWS_ACCOUNT_ID } from "../../../../constants/fastly-aws-account-id"; +import type { GuStack } from "../../../../constructs/core"; +import { GuFastlyCustomerIdParameter } from "../../../../constructs/core"; +import { GuRole } from "../../../../constructs/iam"; +import type { GuKinesisStream } from "../../../../constructs/kinesis"; +import { GuKinesisPutRecordsPolicyExperimental } from "../../policies/kinesis-put-records"; + +export interface GuFastlyKinesisLogRoleProps { + /** + * The Kinesis stream into which Fastly will put records + */ + stream: GuKinesisStream; + /** + * Name of the IAM role + */ + roleName?: string; +} + +/** + * A construct to create an IAM Role for Fastly to assume in order to write to a + * specific Kinesis stream. + * + * In order to use this construct, an SSM parameter named `/account/external/fastly/customer.id` + * needs to exist in the AWS account's parameter store, and the value should be + * the Guardian's Fastly customer id. + * + */ +export class GuFastlyKinesisLogRoleExperimental extends GuRole { + constructor(scope: GuStack, id: string, props: GuFastlyKinesisLogRoleProps) { + const fastlyCustomerId = GuFastlyCustomerIdParameter.getInstance(scope).valueAsString; + const { roleName, stream } = props; + + super(scope, id, { + roleName, + assumedBy: new AccountPrincipal(FASTLY_AWS_ACCOUNT_ID), + externalIds: [fastlyCustomerId], + }); + + const policy = new GuKinesisPutRecordsPolicyExperimental(scope, "GuKinesisPutRecordsPolicy", { + stream, + }); + + policy.attachToRole(this); + } +} diff --git a/src/experimental/constructs/iam/roles/index.ts b/src/experimental/constructs/iam/roles/index.ts new file mode 100644 index 0000000000..5a5cdfab56 --- /dev/null +++ b/src/experimental/constructs/iam/roles/index.ts @@ -0,0 +1 @@ +export * from "./fastly-kinesis-log"; diff --git a/src/experimental/constructs/policies/index.ts b/src/experimental/constructs/policies/index.ts new file mode 100644 index 0000000000..874935f234 --- /dev/null +++ b/src/experimental/constructs/policies/index.ts @@ -0,0 +1 @@ +export * from "./kinesis-put-records"; diff --git a/src/experimental/constructs/policies/kinesis-put-records.test.ts b/src/experimental/constructs/policies/kinesis-put-records.test.ts new file mode 100644 index 0000000000..ad33d0ca0e --- /dev/null +++ b/src/experimental/constructs/policies/kinesis-put-records.test.ts @@ -0,0 +1,28 @@ +import { Template } from "aws-cdk-lib/assertions"; +import { GuKinesisStream } from "../../../constructs/kinesis"; +import { attachPolicyToTestRole, simpleGuStackForTesting } from "../../../utils/test"; +import { GuKinesisPutRecordsPolicyExperimental } from "./kinesis-put-records"; + +describe("The GuKinesisPutRecordsPolicy class", () => { + it("has the correct action permissions", () => { + const stack = simpleGuStackForTesting(); + const stream = new GuKinesisStream(stack, "testStream"); + + const kinesisPutRecordsPolicy = new GuKinesisPutRecordsPolicyExperimental(stack, "KinesisPolicy", { stream }); + + attachPolicyToTestRole(stack, kinesisPutRecordsPolicy); + + Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", { + PolicyDocument: { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["kinesis:PutRecords", "kinesis:ListShards"], + Resource: { "Fn::GetAtt": ["testStream8BCA7523", "Arn"] }, + }, + ], + }, + }); + }); +}); diff --git a/src/experimental/constructs/policies/kinesis-put-records.ts b/src/experimental/constructs/policies/kinesis-put-records.ts new file mode 100644 index 0000000000..6bb37e1c68 --- /dev/null +++ b/src/experimental/constructs/policies/kinesis-put-records.ts @@ -0,0 +1,15 @@ +import type { GuStack } from "../../../constructs/core"; +import type { GuNoStatementsPolicyProps } from "../../../constructs/iam/policies/base-policy"; +import { GuAllowPolicy } from "../../../constructs/iam/policies/base-policy"; +import type { GuKinesisStream } from "../../../constructs/kinesis"; + +export interface GuKinesisPutRecordsPolicyProps extends GuNoStatementsPolicyProps { + stream: GuKinesisStream; +} + +export class GuKinesisPutRecordsPolicyExperimental extends GuAllowPolicy { + constructor(scope: GuStack, id: string, props: GuKinesisPutRecordsPolicyProps) { + const { stream } = props; + super(scope, id, { actions: ["kinesis:PutRecords", "kinesis:ListShards"], resources: [stream.streamArn] }); + } +}