diff --git a/packages/@aws-cdk/mixins-preview/lib/services/aws-logs/vended-logs-helpers.ts b/packages/@aws-cdk/mixins-preview/lib/services/aws-logs/vended-logs-helpers.ts new file mode 100644 index 0000000000000..7f6be3eafb625 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/services/aws-logs/vended-logs-helpers.ts @@ -0,0 +1,83 @@ +import { Aws, Names, Resource, Stack } from 'aws-cdk-lib'; +import { CfnBucketPolicy } from 'aws-cdk-lib/aws-s3'; +import type { IBucketRef } from 'aws-cdk-lib/aws-s3'; +import { CfnResourcePolicy } from 'aws-cdk-lib/aws-xray'; +import type { IConstruct } from 'constructs'; + +/** + * Attempts to find an existing bucket policy for the specified S3 bucket. + * @param bucket - The S3 bucket reference to search for an associated bucket policy + * @returns The bucket policy if found, undefined otherwise + */ +export function tryFindBucketPolicy(bucket: IBucketRef): CfnBucketPolicy | undefined { + const allConstructs = Stack.of(bucket).node.findAll(); + const bucketPolicies = allConstructs.filter(construct => construct instanceof CfnBucketPolicy) as CfnBucketPolicy[]; + const policiesForCurBucket = bucketPolicies.length > 0 ? + bucketPolicies.filter(policy => policy.bucket === bucket.bucketRef.bucketName) : undefined; + return policiesForCurBucket ? policiesForCurBucket[0] : policiesForCurBucket; +} + +/** + * Creates and manages an X-Ray resource policy for log delivery destinations. + * This class is designed as a singleton per stack to manage permissions for multiple log sources. + */ +export class XRayDeliveryDestinationPolicy extends Resource { + /** The CloudFormation X-Ray resource policy */ + public readonly XrayResourcePolicy: CfnResourcePolicy; + /** Array of ARNs for log-generating sources that are allowed to deliver to X-Ray */ + private readonly logGeneratingSourceArns: string[] = []; + + /** + * Creates a new X-Ray delivery destination policy. + * @param scope - The construct scope + * @param id - The construct ID + */ + constructor(scope: IConstruct, id: string) { + super(scope, id); + const stack = Stack.of(scope); + // PolicyGenerator class is a singleton, so we will only ever make one of these per stack + this.XrayResourcePolicy = new CfnResourcePolicy(stack, `CDKXRayPolicy${Names.uniqueId(this)}`, { + policyName: 'CDKXRayDeliveryDestPolicy', + policyDocument: this.buildPolicyDocument(), + }); + } + + /** + * Adds a log-generating source ARN to the policy and updates the resource policy. + * @param logGeneratingSourceArn - The ARN of the log source to add to the policy + */ + public allowSource(logGeneratingSourceArn: string) { + this.logGeneratingSourceArns.push(logGeneratingSourceArn); + this.XrayResourcePolicy.policyDocument = this.buildPolicyDocument(); + } + + /** + * Builds the IAM policy document for X-Ray delivery permissions. + * @returns The policy document as a JSON string + */ + private buildPolicyDocument() { + return JSON.stringify({ + Version: '2012-10-17', + Statement: [{ + Sid: 'CDKLogsDeliveryWrite', + Effect: 'Allow', + Principal: { + Service: 'delivery.logs.amazonaws.com', + }, + Action: 'xray:PutTraceSegments', + Resource: '*', + Condition: { + 'StringEquals': { + 'aws:SourceAccount': Stack.of(this).account, + }, + 'ForAllValues:ArnLike': { + 'logs:LogGeneratingResourceArns': this.logGeneratingSourceArns, + }, + 'ArnLike': { + 'aws:SourceArn': `arn:${Aws.PARTITION}:logs:${Stack.of(this).region}:${Stack.of(this).account}:delivery-source:*`, + }, + }, + }], + }); + } +} diff --git a/packages/@aws-cdk/mixins-preview/lib/services/aws-logs/vended-logs.ts b/packages/@aws-cdk/mixins-preview/lib/services/aws-logs/vended-logs.ts new file mode 100644 index 0000000000000..53ffc14f4fad7 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/lib/services/aws-logs/vended-logs.ts @@ -0,0 +1,303 @@ +import { Aws, Names, Resource, Stack, Tags } from 'aws-cdk-lib'; +import { Effect, PolicyDocument, PolicyStatement, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import type { IDeliveryStreamRef } from 'aws-cdk-lib/aws-kinesisfirehose'; +import { CfnDeliveryDestination, ResourcePolicy } from 'aws-cdk-lib/aws-logs'; +import type { DeliveryDestinationReference, IDeliveryDestinationRef, ILogGroupRef } from 'aws-cdk-lib/aws-logs'; +import { CfnBucketPolicy } from 'aws-cdk-lib/aws-s3'; +import type { IBucketRef } from 'aws-cdk-lib/aws-s3'; +import type { IConstruct } from 'constructs'; +import { tryFindBucketPolicy, XRayDeliveryDestinationPolicy } from './vended-logs-helpers'; + +/** + * Base class for all delivery destination implementations. + * Provides common functionality for log delivery destinations. + */ +abstract class DeliveryDestinationBase extends Resource implements IDeliveryDestinationRef { + /** + * Reference to the delivery destination + */ + public abstract readonly deliveryDestinationRef: DeliveryDestinationReference; +} + +/** + * Configuration properties for S3 delivery destinations. + */ +export interface S3DestinationProps { + /** + * The version of permissions supported by the source generating logs + */ + readonly permissionsVersion: 'V1' | 'V2'; + /** + * The S3 bucket to deliver logs to + */ + readonly s3Bucket: IBucketRef; +} + +/** + * Configuration properties for CloudWatch Logs delivery destinations. + */ +interface LogsDestinationProps { + /** + * The CloudWatch log group to deliver logs to + */ + readonly logGroup: ILogGroupRef; +} + +/** + * Configuration properties for Kinesis Data Firehose delivery destinations. + */ +interface FirehoseDestinationProps { + /** + * The Kinesis Data Firehose delivery stream to deliver logs to + */ + readonly deliveryStream: IDeliveryStreamRef; +} + +/** + * Creates a delivery destination for S3 buckets with appropriate IAM permissions. + * Supports both V1 and V2 permissions for S3 bucket access. + */ +export class S3DeliveryDestination extends DeliveryDestinationBase { + /** + * Reference to the S3 delivery destination + */ + public readonly deliveryDestinationRef: DeliveryDestinationReference; + + /** + * Creates a new S3 delivery destination. + * @param scope - The construct scope + * @param id - The construct ID + * @param props - Configuration properties for the S3 destination + */ + constructor(scope: IConstruct, id: string, props: S3DestinationProps) { + super(scope, id); + const bucketPolicy = this.getOrCreateBucketPolicy(scope, props); + + const destinationNamePrefix = 'cdk-s3-dest-'; + const deliveryDestination = new CfnDeliveryDestination(scope, `CDKS3Dest${Names.uniqueId(this)}`, { + destinationResourceArn: props.s3Bucket.bucketRef.bucketArn, + name: `${destinationNamePrefix}${Names.uniqueResourceName(this, { maxLength: 60 - destinationNamePrefix.length })}`, + deliveryDestinationType: 'S3', + }); + deliveryDestination.node.addDependency(bucketPolicy); + this.deliveryDestinationRef = deliveryDestination.deliveryDestinationRef; + } + + /** + * Gets an existing bucket policy or creates a new one and adds the required permissions for log delivery. + * @param scope - The construct scope + * @param bucketProps - The S3 bucket properties + * @returns The bucket policy with log delivery permissions + */ + private getOrCreateBucketPolicy(scope: IConstruct, bucketProps: S3DestinationProps): CfnBucketPolicy { + const existingPolicy = tryFindBucketPolicy(bucketProps.s3Bucket); + const statements = []; + + const bucketStatement = new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new ServicePrincipal('delivery.logs.amazonaws.com')], + actions: ['s3:PutObject'], + resources: [`${bucketProps.s3Bucket.bucketRef.bucketArn}/AWSLogs/${Stack.of(scope).account}/*`], + conditions: { + StringEquals: { + 's3:x-amz-acl': 'bucket-owner-full-control', + 'aws:SourceAccount': Stack.of(scope).account, + }, + ArnLike: { + 'aws:SourceArn': `arn:${Aws.PARTITION}:logs:${Stack.of(scope).region}:${Stack.of(scope).account}:delivery-source:*`, + }, + }, + }); + statements.push(bucketStatement); + + if (bucketProps.permissionsVersion == 'V1') { + const v1PermsStatement = new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new ServicePrincipal('delivery.logs.amazonaws.com')], + actions: ['s3:GetBucketAcl', 's3:ListBucket'], + resources: [bucketProps.s3Bucket.bucketRef.bucketArn], + conditions: { + StringEquals: { + 'aws:SourceAccount': Stack.of(scope).account, + }, + ArnLike: { + 'aws:SourceArn': `arn:${Aws.PARTITION}:logs:${Stack.of(scope).region}:${Stack.of(scope).account}:*`, + }, + }, + }); + statements.push(v1PermsStatement); + } + + if (existingPolicy) { + const bucketPolicy = existingPolicy; + + // Add new statements using addOverride to avoid circular references + const existingDoc = bucketPolicy.policyDocument as any; + const existingStatements = Array.isArray(existingDoc.Statement) ? existingDoc.Statement : []; + const newStatements = statements.map(s => s.toStatementJson()); + + bucketPolicy.addOverride('Properties.PolicyDocument.Statement', [ + ...existingStatements, + ...newStatements, + ]); + return bucketPolicy; + } else { + return new CfnBucketPolicy(scope, `CDKS3DestPolicy${Names.uniqueId(bucketProps.s3Bucket)}`, { + bucket: bucketProps.s3Bucket.bucketRef.bucketName, + policyDocument: new PolicyDocument({ + statements, + }).toJSON(), + }); + } + } +} + +/** + * Creates a delivery destination for Kinesis Data Firehose delivery streams. + * Automatically tags the delivery stream to enable log delivery. + */ +export class FirehoseDeliveryDestination extends DeliveryDestinationBase { + /** + * Reference to the Firehose delivery destination + */ + public readonly deliveryDestinationRef: DeliveryDestinationReference; + + /** + * Creates a new Firehose delivery destination. + * @param scope - The construct scope + * @param id - The construct ID + * @param props - Configuration properties for the Firehose destination + */ + constructor(scope: IConstruct, id: string, props: FirehoseDestinationProps) { + super(scope, id); + + Tags.of(props.deliveryStream).add('LogDeliveryEnabled', 'true'); + const destinationNamePrefix = 'cdk-fh-dest-'; + const deliveryDestination = new CfnDeliveryDestination(scope, `CDKFHDest${Names.uniqueId(this)}`, { + destinationResourceArn: props.deliveryStream.deliveryStreamRef.deliveryStreamArn, + name: `${destinationNamePrefix}${Names.uniqueResourceName(this, { maxLength: 60 - destinationNamePrefix.length })}`, + deliveryDestinationType: 'FH', + }); + this.deliveryDestinationRef = deliveryDestination.deliveryDestinationRef; + } +} + +/** + * Creates a delivery destination for CloudWatch Logs log groups. + * Manages the required resource policy for cross-account log delivery. + */ +export class LogsDeliveryDestination extends DeliveryDestinationBase { + /** + * Reference to the CloudWatch Logs delivery destination + */ + public readonly deliveryDestinationRef: DeliveryDestinationReference; + + /** + * Creates a new CloudWatch Logs delivery destination. + * @param scope - The construct scope + * @param id - The construct ID + * @param props - Configuration properties for the CloudWatch Logs destination + */ + constructor(scope: IConstruct, id: string, props: LogsDestinationProps) { + super(scope, id); + + const logGroupPolicy = this.getOrCreateLogsResourcePolicy(scope, props.logGroup); + + const destinationNamePrefix = 'cdk-cwl-dest-'; + const deliveryDestination = new CfnDeliveryDestination(scope, `CDKCWLDest${Names.uniqueId(this)}`, { + destinationResourceArn: props.logGroup.logGroupRef.logGroupArn, + name: `${destinationNamePrefix}${Names.uniqueResourceName(this, { maxLength: 60 - destinationNamePrefix.length })}`, + deliveryDestinationType: 'CWL', + }); + deliveryDestination.node.addDependency(logGroupPolicy); + this.deliveryDestinationRef = deliveryDestination.deliveryDestinationRef; + } + + /** + * Uses singleton pattern to get an existing CloudWatch Logs resource policy or create a new one. + * Adds log delivery permissions to the policy. + * @param scope - The construct scope + * @param logGroup - The target log group + * @returns The resource policy with log delivery permissions + */ + private getOrCreateLogsResourcePolicy(scope: IConstruct, logGroup: ILogGroupRef) { + const stack = Stack.of(scope); + const policyId = 'CDKCWLLogDestDeliveryPolicy'; + const exists = stack.node.tryFindChild(policyId) as ResourcePolicy; + + const logGroupDeliveryStatement = new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new ServicePrincipal('delivery.logs.amazonaws.com')], + actions: ['logs:CreateLogStream', 'logs:PutLogEvents'], + resources: [`${logGroup.logGroupRef.logGroupArn}:log-stream:*`], + conditions: { + StringEquals: { + 'aws:SourceAccount': Stack.of(stack).account, + }, + ArnLike: { + 'aws:SourceArn': `arn:${Aws.PARTITION}:logs:${Stack.of(stack).region}:${Stack.of(stack).account}:*`, + }, + }, + }); + + if (exists) { + exists.document.addStatements(logGroupDeliveryStatement); + return exists; + } + const logGroupPolicy = new ResourcePolicy(stack, policyId, { + resourcePolicyName: 'LogDestinationDeliveryPolicy', + policyStatements: [ + logGroupDeliveryStatement, + ], + }); + return logGroupPolicy; + } +} + +/** + * Creates a delivery destination for AWS X-Ray tracing. + * Manages the X-Ray resource policy for log delivery permissions. + */ +export class XRayDeliveryDestination extends DeliveryDestinationBase { + /** Reference to the X-Ray delivery destination */ + public readonly deliveryDestinationRef: DeliveryDestinationReference; + /** + * The X-Ray resource policy manager + */ + public readonly xrayResourcePolicy: XRayDeliveryDestinationPolicy; + + /** + * Creates a new X-Ray delivery destination. + * @param scope - The construct scope + * @param id - The construct ID + */ + constructor(scope: IConstruct, id: string) { + super(scope, id); + // only have one of these per stack + this.xrayResourcePolicy = this.getOrCreateXRayPolicyGenerator(scope); + + const destinationNamePrefix = 'cdk-xray-dest-'; + const deliveryDestination = new CfnDeliveryDestination(scope, `CDKXRayDest${Names.uniqueId(this)}`, { + name: `${destinationNamePrefix}${Names.uniqueResourceName(this, { maxLength: 60 - destinationNamePrefix.length })}`, + deliveryDestinationType: 'XRAY', + }); + this.deliveryDestinationRef = deliveryDestination.deliveryDestinationRef; + } + + /** + * Gets an existing X-Ray policy generator or creates a new one. + * Ensures only one X-Ray policy generator exists per stack. + * @param scope - The construct scope + * @returns The X-Ray delivery destination policy manager + */ + private getOrCreateXRayPolicyGenerator(scope: IConstruct) { + const stack = Stack.of(scope); + const poliyGeneratorId = 'CDKXRayPolicyGenerator'; + const exists = stack.node.tryFindChild(poliyGeneratorId) as XRayDeliveryDestinationPolicy; + + if (exists) { + return exists; + } + return new XRayDeliveryDestinationPolicy(stack, poliyGeneratorId); + } +} diff --git a/packages/@aws-cdk/mixins-preview/test/services/aws-logs/vended-logs-helpers.test.ts b/packages/@aws-cdk/mixins-preview/test/services/aws-logs/vended-logs-helpers.test.ts new file mode 100644 index 0000000000000..9cfa75ed0f0b9 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/test/services/aws-logs/vended-logs-helpers.test.ts @@ -0,0 +1,101 @@ +import { Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { Bucket, BucketPolicy } from 'aws-cdk-lib/aws-s3'; +import { tryFindBucketPolicy, XRayDeliveryDestinationPolicy } from '../../../lib/services/aws-logs/vended-logs-helpers'; +import { AccountRootPrincipal, Effect, PolicyDocument, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; + +describe('getOrCreateBucketPolicy', () => { + test('if a bucket policy exists on a bucket, return it', () => { + const stack = new Stack(); + const bucket = new Bucket(stack, 'TestBucket'); + + new BucketPolicy(stack, 'S3BucketPolicy', { + bucket: bucket, + document: new PolicyDocument({ + statements: [new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new AccountRootPrincipal()], + actions: ['s3:GetObject'], + resources: [bucket.arnForObjects('*')], + })], + }), + }); + + const output = tryFindBucketPolicy(bucket); + + expect(output).toBeDefined(); + }); + + test('if a bucket policy does not exist on a bucket, return undefined', () => { + const stack = new Stack(); + const bucket = new Bucket(stack, 'TestBucket'); + + const output = tryFindBucketPolicy(bucket); + + expect(output).toBeUndefined(); + }); +}); + +describe('XRayDeliveryDestinationPolicy', () => { + test('creates an XRay delivery policy', () => { + const stack = new Stack(); + + new XRayDeliveryDestinationPolicy(stack, 'CDKXRayPolicyGenerator'); + + Template.fromStack(stack).resourceCountIs('AWS::XRay::ResourcePolicy', 1); + Template.fromStack(stack).hasResourceProperties('AWS::XRay::ResourcePolicy', { + PolicyDocument: { + 'Fn::Join': [ + '', + [ + '{"Version":"2012-10-17","Statement":[{"Sid":"CDKLogsDeliveryWrite","Effect":"Allow","Principal":{"Service":"delivery.logs.amazonaws.com"},"Action":"xray:PutTraceSegments","Resource":"*","Condition":{"StringEquals":{"aws:SourceAccount":"', + { + Ref: 'AWS::AccountId', + }, + '"},"ForAllValues:ArnLike":{"logs:LogGeneratingResourceArns":[]},"ArnLike":{"aws:SourceArn":"arn:', + { + Ref: 'AWS::Partition', + }, + ':logs:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':delivery-source:*"}}}]}', + ], + ], + }, + }); + }); + + test('XRay Delivery resource policy gets updated with log delivery sources', () => { + const stack = new Stack(); + + const xray = new XRayDeliveryDestinationPolicy(stack, 'CDKXRayPolicyGenerator'); + + const bucket = new Bucket(stack, 'XRayTestBucket'); + const secret = new Secret(stack, 'XRayTestSecret', { + description: 'Sample secret with arn to use for XRay', + }); + + xray.allowSource(bucket.bucketArn); + xray.allowSource(secret.secretArn); + + Template.fromStack(stack).hasResourceProperties('AWS::XRay::ResourcePolicy', { + PolicyDocument: { + 'Fn::Join': [ + '', + Match.arrayWith([ + Match.stringLikeRegexp('.*logs:LogGeneratingResourceArns.*'), + { 'Fn::GetAtt': ['XRayTestBucketEE28F545', 'Arn'] }, + { Ref: 'XRayTestSecret0AF068A2' }, + ]), + ], + }, + }); + }); +}); diff --git a/packages/@aws-cdk/mixins-preview/test/services/aws-logs/vended-logs.test.ts b/packages/@aws-cdk/mixins-preview/test/services/aws-logs/vended-logs.test.ts new file mode 100644 index 0000000000000..25e4a57f52569 --- /dev/null +++ b/packages/@aws-cdk/mixins-preview/test/services/aws-logs/vended-logs.test.ts @@ -0,0 +1,702 @@ +import { Stack } from 'aws-cdk-lib'; +import { Bucket, BucketPolicy, CfnBucketPolicy } from 'aws-cdk-lib/aws-s3'; +import { FirehoseDeliveryDestination, LogsDeliveryDestination, S3DeliveryDestination, XRayDeliveryDestination } from '../../../lib/services/aws-logs/vended-logs'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { AccountRootPrincipal, Effect, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { DeliveryStream, S3Bucket } from 'aws-cdk-lib/aws-kinesisfirehose'; +import { LogGroup, ResourcePolicy, RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; + +describe('S3 Delivery Destination', () => { + test('creates an S3 delivery destination when given a bucket', () => { + const stack = new Stack(); + const bucket = new Bucket(stack, 'Destination'); + + new S3DeliveryDestination(stack, 'S3Destination', { + permissionsVersion: 'V2', + s3Bucket: bucket, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::Logs::DeliveryDestination', { + DeliveryDestinationType: 'S3', + DestinationResourceArn: { + 'Fn::GetAtt': [ + 'Destination920A3C57', + 'Arn', + ], + }, + Name: Match.stringLikeRegexp('cdk-s3-dest-.*'), + }); + + // Validate that DeliveryDestination depends on the S3 bucket policy + const template = Template.fromStack(stack); + const deliveryDestinations = template.findResources('AWS::Logs::DeliveryDestination'); + const bucketPolicies = template.findResources('AWS::S3::BucketPolicy'); + + const deliveryDestinationLogicalId = Object.keys(deliveryDestinations)[0]; + const bucketPolicyLogicalId = Object.keys(bucketPolicies)[0]; + + expect(deliveryDestinations[deliveryDestinationLogicalId].DependsOn).toContain(bucketPolicyLogicalId); + }); + + test('able to make multiple delivery destinations that use the same bucket', () => { + const stack = new Stack(); + const bucket = new Bucket(stack, 'Destination'); + + new S3DeliveryDestination(stack, 'S3Destination1', { + permissionsVersion: 'V1', + s3Bucket: bucket, + }); + + new S3DeliveryDestination(stack, 'S3Destination2', { + permissionsVersion: 'V2', + s3Bucket: bucket, + }); + + Template.fromStack(stack).resourceCountIs('AWS::S3::BucketPolicy', 1); + Template.fromStack(stack).resourceCountIs('AWS::Logs::DeliveryDestination', 2); + }); + + test('creates policy with V1 permissions if specified', () => { + const stack = new Stack(); + const bucket = new Bucket(stack, 'Destination'); + + new S3DeliveryDestination(stack, 'S3Destination', { + permissionsVersion: 'V1', + s3Bucket: bucket, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::S3::BucketPolicy', { + Bucket: { + Ref: 'Destination920A3C57', + }, + PolicyDocument: { + Statement: [ + { + Action: 's3:PutObject', + Condition: { + StringEquals: { + 's3:x-amz-acl': 'bucket-owner-full-control', + 'aws:SourceAccount': { + Ref: 'AWS::AccountId', + }, + }, + ArnLike: { + 'aws:SourceArn': { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':logs:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':delivery-source:*', + ], + ], + }, + }, + }, + Effect: 'Allow', + Principal: { + Service: 'delivery.logs.amazonaws.com', + }, + Resource: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'Destination920A3C57', + 'Arn', + ], + }, + '/AWSLogs/', + { + Ref: 'AWS::AccountId', + }, + '/*', + ], + ], + }, + }, + { + Action: [ + 's3:GetBucketAcl', + 's3:ListBucket', + ], + Condition: { + StringEquals: { + 'aws:SourceAccount': { + Ref: 'AWS::AccountId', + }, + }, + ArnLike: { + 'aws:SourceArn': { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':logs:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':*', + ], + ], + }, + }, + }, + Effect: 'Allow', + Principal: { + Service: 'delivery.logs.amazonaws.com', + }, + Resource: { + 'Fn::GetAtt': [ + 'Destination920A3C57', + 'Arn', + ], + }, + }, + ], + }, + }); + }); + + test('creates policy with V2 permissions if specified', () => { + const stack = new Stack(); + const bucket = new Bucket(stack, 'Destination'); + + new S3DeliveryDestination(stack, 'S3Destination', { + permissionsVersion: 'V2', + s3Bucket: bucket, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::S3::BucketPolicy', { + Bucket: { + Ref: 'Destination920A3C57', + }, + PolicyDocument: { + Statement: [ + { + Action: 's3:PutObject', + Condition: { + StringEquals: { + 's3:x-amz-acl': 'bucket-owner-full-control', + 'aws:SourceAccount': { + Ref: 'AWS::AccountId', + }, + }, + ArnLike: { + 'aws:SourceArn': { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':logs:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':delivery-source:*', + ], + ], + }, + }, + }, + Effect: 'Allow', + Principal: { + Service: 'delivery.logs.amazonaws.com', + }, + Resource: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'Destination920A3C57', + 'Arn', + ], + }, + '/AWSLogs/', + { + Ref: 'AWS::AccountId', + }, + '/*', + ], + ], + }, + }, + ], + }, + }); + }); + + test('adds to existing policy if a BucketPolicy already exists', () => { + const stack = new Stack(); + const bucket = new Bucket(stack, 'Destination'); + + new BucketPolicy(stack, 'S3BucketPolicy', { + bucket: bucket, + document: new PolicyDocument({ + statements: [new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new AccountRootPrincipal()], + actions: ['s3:GetObject'], + resources: [bucket.arnForObjects('*')], + })], + }), + }); + + new S3DeliveryDestination(stack, 'S3Destination', { + permissionsVersion: 'V2', + s3Bucket: bucket, + }); + + Template.fromStack(stack).resourceCountIs('AWS::S3::BucketPolicy', 1); + }); + + test('adds to existing policy if a CfnBucketPolicy already exists', () => { + const stack = new Stack(); + const bucket = new Bucket(stack, 'Destination'); + + new CfnBucketPolicy(stack, 'S3BucketPolicy', { + bucket: bucket.bucketName, + policyDocument: new PolicyDocument({ + statements: [new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new AccountRootPrincipal()], + actions: ['s3:GetObject'], + resources: [bucket.arnForObjects('*')], + })], + }).toJSON(), + }); + + new S3DeliveryDestination(stack, 'S3Destination', { + permissionsVersion: 'V2', + s3Bucket: bucket, + }); + + Template.fromStack(stack).resourceCountIs('AWS::S3::BucketPolicy', 1); + Template.fromStack(stack).hasResourceProperties('AWS::S3::BucketPolicy', { + Bucket: { + Ref: 'Destination920A3C57', + }, + PolicyDocument: { + Statement: [ + { + Action: 's3:GetObject', + Effect: 'Allow', + Principal: { + AWS: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':root', + ], + ], + }, + }, + Resource: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'Destination920A3C57', + 'Arn', + ], + }, + '/*', + ], + ], + }, + }, + { + Action: 's3:PutObject', + Condition: { + StringEquals: { + 's3:x-amz-acl': 'bucket-owner-full-control', + 'aws:SourceAccount': { + Ref: 'AWS::AccountId', + }, + }, + ArnLike: { + 'aws:SourceArn': { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':logs:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':delivery-source:*', + ], + ], + }, + }, + }, + Effect: 'Allow', + Principal: { + Service: 'delivery.logs.amazonaws.com', + }, + Resource: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'Destination920A3C57', + 'Arn', + ], + }, + '/AWSLogs/', + { + Ref: 'AWS::AccountId', + }, + '/*', + ], + ], + }, + }, + ], + Version: '2012-10-17', + }, + }, + ); + }); +}); + +describe('Cloudwatch Logs Delivery Destination', () => { + test('creates a Cloudwatch Delivery Destination when given a log group', () => { + const stack = new Stack(); + const logGroup = new LogGroup(stack, 'LogGroupDelivery', { + logGroupName: 'test-log-group', + retention: RetentionDays.ONE_WEEK, + }); + + new LogsDeliveryDestination(stack, 'CloudwatchDelivery', { + logGroup, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::Logs::DeliveryDestination', { + DeliveryDestinationType: 'CWL', + DestinationResourceArn: { + 'Fn::GetAtt': [ + 'LogGroupDelivery0EF9ECE4', + 'Arn', + ], + }, + Name: Match.stringLikeRegexp('cdk-cwl-dest-.*'), + }); + + Template.fromStack(stack).hasResourceProperties('AWS::Logs::ResourcePolicy', { + PolicyDocument: { + 'Fn::Join': [ + '', + [ + '{"Statement":[{"Action":["logs:CreateLogStream","logs:PutLogEvents"],"Condition":{"StringEquals":{"aws:SourceAccount":"', + { + Ref: 'AWS::AccountId', + }, + '"},"ArnLike":{"aws:SourceArn":"arn:', + { + Ref: 'AWS::Partition', + }, + ':logs:', + { + Ref: 'AWS::Region', + }, + ':', + { + Ref: 'AWS::AccountId', + }, + ':*"}},"Effect":"Allow","Principal":{"Service":"delivery.logs.amazonaws.com"},"Resource":"', + { + 'Fn::GetAtt': [ + 'LogGroupDelivery0EF9ECE4', + 'Arn', + ], + }, + ':log-stream:*"}],"Version":"2012-10-17"}', + ], + ], + }, + PolicyName: 'LogDestinationDeliveryPolicy', + }); + + // Validate that DeliveryDestination depends on the Cloudwatch resource policy + const template = Template.fromStack(stack); + const deliveryDestinations = template.findResources('AWS::Logs::DeliveryDestination'); + const resourcePolicies = template.findResources('AWS::Logs::ResourcePolicy'); + + const deliveryDestinationLogicalId = Object.keys(deliveryDestinations)[0]; + const cwlPolicyLogicalId = Object.keys(resourcePolicies)[0]; + + expect(deliveryDestinations[deliveryDestinationLogicalId].DependsOn).toContain(cwlPolicyLogicalId); + }); + + test('if there is an exsiting Cloudwatch resource policy but it is not attached to the root of the stack, make a new one', () => { + const stack = new Stack(); + const logGroup = new LogGroup(stack, 'LogGroupDelivery', { + logGroupName: 'test-log-group', + retention: RetentionDays.ONE_WEEK, + }); + + logGroup.addToResourcePolicy(new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new ServicePrincipal('vpc-flow-logs.amazonaws.com')], + actions: ['logs:GetLogEvents', 'logs:FilterLogEvents'], + resources: [logGroup.logGroupArn], + })); + + new LogsDeliveryDestination(stack, 'CloudwatchDelivery', { + logGroup, + }); + + Template.fromStack(stack).resourceCountIs('AWS::Logs::ResourcePolicy', 2); + }); + + test('if there is an existing Cloudwatch resource policy at the root of the stack, update it', () => { + const stack = new Stack(); + new ResourcePolicy(stack, 'CDKCWLLogDestDeliveryPolicy', { + resourcePolicyName: 'singletonPolicy', + policyStatements: [], + }); + const logGroup = new LogGroup(stack, 'LogGroupDelivery', { + logGroupName: 'test-log-group', + retention: RetentionDays.ONE_WEEK, + }); + + new LogsDeliveryDestination(stack, 'CloudwatchDelivery', { + logGroup, + }); + + Template.fromStack(stack).resourceCountIs('AWS::Logs::ResourcePolicy', 1); + }); + + test('creates only one resource policy if multiple Log Delivery Destinations are created', () => { + const stack = new Stack(); + const logGroup1 = new LogGroup(stack, 'LogGroup1Delivery', { + logGroupName: 'test-1-log-group', + retention: RetentionDays.ONE_WEEK, + }); + + const logGroup2 = new LogGroup(stack, 'LogGroup2Delivery', { + logGroupName: 'test-2-log-group', + retention: RetentionDays.ONE_WEEK, + }); + + new LogsDeliveryDestination(stack, 'CloudwatchDelivery1', { + logGroup: logGroup1, + }); + + new LogsDeliveryDestination(stack, 'CloudwatchDelivery2', { + logGroup: logGroup2, + }); + + Template.fromStack(stack).resourceCountIs('AWS::Logs::ResourcePolicy', 1); + }); + + test('creates a new resource policy if there is an existing resource policy unrelated to Cloudwatch logs', () => { + const stack = new Stack(); + const logGroup = new LogGroup(stack, 'LogGroupDelivery', { + logGroupName: 'test-log-group', + retention: RetentionDays.ONE_WEEK, + }); + + const secret = new Secret(stack, 'MySecret', { + description: 'Sample secret with resource policy', + }); + + secret.addToResourcePolicy(new PolicyStatement({ + effect: Effect.ALLOW, + principals: [new AccountRootPrincipal()], + actions: ['secretsmanager:GetSecretValue'], + resources: ['*'], + conditions: { + StringEquals: { + 'secretsmanager:ResourceTag/Environment': 'Production', + }, + }, + })); + + new LogsDeliveryDestination(stack, 'CloudwatchDelivery', { + logGroup, + }); + + Template.fromStack(stack).resourceCountIs('AWS::Logs::ResourcePolicy', 1); + Template.fromStack(stack).resourceCountIs('AWS::SecretsManager::ResourcePolicy', 1); + }); + + test('able to make multiple delivery destinations with the same log group', () => { + const stack = new Stack(); + const logGroup = new LogGroup(stack, 'LogGroupDelivery', { + logGroupName: 'test-log-group', + retention: RetentionDays.ONE_WEEK, + }); + + new LogsDeliveryDestination(stack, 'CloudwatchDelivery1', { + logGroup, + }); + + new LogsDeliveryDestination(stack, 'CloudwatchDelivery2', { + logGroup, + }); + + Template.fromStack(stack).resourceCountIs('AWS::Logs::ResourcePolicy', 1); + Template.fromStack(stack).resourceCountIs('AWS::Logs::DeliveryDestination', 2); + }); +}); + +describe('Firehose Stream Delivery Destination', () => { + test('creates a Firehose Delivery Destination when given a delivery stream', () => { + const stack = new Stack(); + const streamBucket = new Bucket(stack, 'DeliveryBucket', {}); + const firehoseRole = new Role(stack, 'FirehoseRole', { + assumedBy: new ServicePrincipal('firehose.amazonaws.com'), + inlinePolicies: { + S3Access: new PolicyDocument({ + statements: [ + new PolicyStatement({ + actions: ['s3:PutObject', 's3:GetObject', 's3:ListBucket'], + resources: [streamBucket.bucketArn, `${streamBucket.bucketArn}/*`], + }), + ], + }), + }, + }); + const stream = new DeliveryStream(stack, 'Firehose', { + destination: new S3Bucket(streamBucket), + role: firehoseRole, + }); + + new FirehoseDeliveryDestination(stack, 'FirehoseDelivery', { + deliveryStream: stream, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::Logs::DeliveryDestination', { + DeliveryDestinationType: 'FH', + DestinationResourceArn: { + 'Fn::GetAtt': [ + 'FirehoseEF5AC2A2', + 'Arn', + ], + }, + Name: Match.stringLikeRegexp('cdk-fh-dest-.*'), + }); + + Template.fromStack(stack).hasResourceProperties('AWS::KinesisFirehose::DeliveryStream', { + Tags: Match.arrayWith([Match.objectLike({ + Key: 'LogDeliveryEnabled', + Value: 'true', + })]), + }); + }); + + test('able to make multiple delivery destinations that use the same stream', () => { + const stack = new Stack(); + const streamBucket = new Bucket(stack, 'DeliveryBucket', {}); + const firehoseRole = new Role(stack, 'FirehoseRole', { + assumedBy: new ServicePrincipal('firehose.amazonaws.com'), + inlinePolicies: { + S3Access: new PolicyDocument({ + statements: [ + new PolicyStatement({ + actions: ['s3:PutObject', 's3:GetObject', 's3:ListBucket'], + resources: [streamBucket.bucketArn, `${streamBucket.bucketArn}/*`], + }), + ], + }), + }, + }); + const stream = new DeliveryStream(stack, 'Firehose', { + destination: new S3Bucket(streamBucket), + role: firehoseRole, + }); + + new FirehoseDeliveryDestination(stack, 'FirehoseDelivery1', { + deliveryStream: stream, + }); + + new FirehoseDeliveryDestination(stack, 'FirehoseDelivery2', { + deliveryStream: stream, + }); + + Template.fromStack(stack).resourceCountIs('AWS::Logs::DeliveryDestination', 2); + }); +}); + +describe('XRay Delivery Destination', () => { + test('creates an XRay Delivery destination', () => { + const stack = new Stack(); + + new XRayDeliveryDestination(stack, 'XRayDestination'); + + Template.fromStack(stack).hasResourceProperties('AWS::Logs::DeliveryDestination', { + DeliveryDestinationType: 'XRAY', + Name: Match.stringLikeRegexp('cdk-xray-dest-.*'), + }); + }); + + test('when multiple XRay Delivery destinations are created on one stack, only create one XRay resource policy', () => { + const stack = new Stack(); + + new XRayDeliveryDestination(stack, 'XRayDestination1'); + + new XRayDeliveryDestination(stack, 'XRayDestination2'); + + Template.fromStack(stack).resourceCountIs('AWS::XRay::ResourcePolicy', 1); + Template.fromStack(stack).resourceCountIs('AWS::Logs::DeliveryDestination', 2); + }); + + test('if XRay Delivery Destinations are in different stacks, make different policies', () => { + const stack1 = new Stack(); + const stack2 = new Stack(); + + new XRayDeliveryDestination(stack1, 'XRayDestination1'); + + new XRayDeliveryDestination(stack2, 'XRayDestination2'); + + Template.fromStack(stack1).resourceCountIs('AWS::XRay::ResourcePolicy', 1); + Template.fromStack(stack1).resourceCountIs('AWS::Logs::DeliveryDestination', 1); + Template.fromStack(stack2).resourceCountIs('AWS::XRay::ResourcePolicy', 1); + Template.fromStack(stack2).resourceCountIs('AWS::Logs::DeliveryDestination', 1); + }); +});