From 1462aeadb8389902a2b7f63f386a8543ffe58591 Mon Sep 17 00:00:00 2001 From: Chris Fane Date: Tue, 6 May 2025 11:29:10 +0100 Subject: [PATCH 1/4] Amazon CloudFront Std Logging V2 Sample, demonstrating Kinesis, CloudWatch and S3 Partitioned outputs --- python/cloudfront-v2-logging/README.md | 202 +++++++++++ python/cloudfront-v2-logging/app.py | 54 +++ .../architecture.drawio.png | Bin 0 -> 53450 bytes python/cloudfront-v2-logging/cdk.json | 86 +++++ .../cloudfront_v2_logging/__init__.py | 0 .../cloudfront_v2_logging_stack.py | 342 ++++++++++++++++++ python/cloudfront-v2-logging/requirements.txt | 3 + python/cloudfront-v2-logging/source.bat | 13 + .../cloudfront-v2-logging/website/index.html | 10 + 9 files changed, 710 insertions(+) create mode 100644 python/cloudfront-v2-logging/README.md create mode 100644 python/cloudfront-v2-logging/app.py create mode 100644 python/cloudfront-v2-logging/architecture.drawio.png create mode 100644 python/cloudfront-v2-logging/cdk.json create mode 100644 python/cloudfront-v2-logging/cloudfront_v2_logging/__init__.py create mode 100644 python/cloudfront-v2-logging/cloudfront_v2_logging/cloudfront_v2_logging_stack.py create mode 100644 python/cloudfront-v2-logging/requirements.txt create mode 100644 python/cloudfront-v2-logging/source.bat create mode 100644 python/cloudfront-v2-logging/website/index.html diff --git a/python/cloudfront-v2-logging/README.md b/python/cloudfront-v2-logging/README.md new file mode 100644 index 0000000000..0c7591129e --- /dev/null +++ b/python/cloudfront-v2-logging/README.md @@ -0,0 +1,202 @@ +# CloudFront V2 Logging with AWS CDK (Python) + +This project demonstrates how to set up Amazon CloudFront with the new CloudFront Standard Logging V2 feature using AWS CDK in Python. The example shows how to configure multiple logging destinations for CloudFront access logs, including: + +1. Amazon CloudWatch Logs +2. Amazon S3 (with Parquet format) +3. Amazon Kinesis Data Firehose (with JSON format) + +## Architecture + +![CloudFront V2 Logging Architecture](./architecture.drawio.png) + +The project deploys the following resources: + +- An S3 bucket to host a simple static website +- A CloudFront distribution with Origin Access Control (OAC) to serve the website +- A logging S3 bucket with appropriate lifecycle policies +- CloudFront Standard Logging V2 configuration with multiple delivery destinations +- Kinesis Data Firehose delivery stream +- CloudWatch Logs group +- Necessary IAM roles and permissions + +## Prerequisites + +- [AWS CLI](https://aws.amazon.com/cli/) configured with appropriate credentials +- [AWS CDK](https://aws.amazon.com/cdk/) installed (v2.x) +- Python 3.6 or later +- Node.js 14.x or later (for CDK) + +## Setup + +1. Create and activate a virtual environment: + +```bash +python3 -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate.bat +``` + +2. Install the required dependencies: + +```bash +pip install -r requirements.txt +``` + +3. Synthesize the CloudFormation template: + +```bash +cdk synth +``` + +4. Deploy the stack: + +```bash +cdk deploy +``` + +You can customize the log retention periods by providing parameters: + +```bash +cdk deploy --parameters LogRetentionDays=90 --parameters CloudWatchLogRetentionDays=60 +``` + +5. After deployment, the CloudFront distribution domain name will be displayed in the outputs. You can access your website using this domain. + +## How It Works + +This example demonstrates CloudFront Standard Logging V2, which provides more flexibility in how you collect and analyze CloudFront access logs: + +- **CloudWatch Logs**: Logs are delivered in JSON format for real-time monitoring and analysis +- **S3 (Parquet)**: Logs are delivered in Parquet format with Hive-compatible paths for efficient querying with services like Amazon Athena +- **Kinesis Data Firehose**: Logs are streamed in JSON format, allowing for real-time processing and transformation + +The CDK stack creates all necessary resources and configures the appropriate permissions for log delivery. + +## Example Log Outputs + +### CloudWatch Logs (JSON format) +```json +{ + "timestamp": "2023-03-15T20:12:34Z", + "c-ip": "192.0.2.100", + "time-to-first-byte": 0.002, + "sc-status": 200, + "sc-bytes": 2326, + "cs-method": "GET", + "cs-uri-stem": "/index.html", + "cs-protocol": "https", + "cs-host": "d111111abcdef8.cloudfront.net", + "cs-user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "cs-referer": "https://www.example.com/", + "x-edge-location": "IAD79-C2", + "x-edge-request-id": "tLAGM_r7TyiRgwgk_4U5Xb-vv4JHOjzGCh61ER9nM_2UFY8hTKdEoQ==" +} +``` + +### S3 Parquet Format +The Parquet format is a columnar storage format that provides efficient compression and encoding schemes. The logs are stored in a Hive-compatible directory structure: + +``` +s3://your-logging-bucket/s3_delivery/EDFDVBD6EXAMPLE/2023/03/15/20/ +``` + +### Kinesis Data Firehose (JSON format) +Firehose delivers logs in JSON format with a timestamp-based prefix: + +``` +s3://your-logging-bucket/firehose_delivery/year=2023/month=03/day=15/delivery-stream-1-2023-03-15-20-12-34-a1b2c3d4.json.gz +``` + +## Querying Logs with Athena + +You can use Amazon Athena to query the Parquet logs stored in S3. Here's an example query to get started: + +```sql +CREATE EXTERNAL TABLE IF NOT EXISTS cloudfront_logs ( + `timestamp` string, + `c-ip` string, + `time-to-first-byte` float, + `sc-status` int, + `sc-bytes` bigint, + `cs-method` string, + `cs-uri-stem` string, + `cs-protocol` string, + `cs-host` string, + `cs-user-agent` string, + `cs-referer` string, + `x-edge-location` string, + `x-edge-request-id` string +) +PARTITIONED BY ( + `distributionid` string, + `year` string, + `month` string, + `day` string, + `hour` string +) +STORED AS PARQUET +LOCATION 's3://your-logging-bucket/s3_delivery/'; + +-- Update partitions +MSCK REPAIR TABLE cloudfront_logs; + +-- Example query to find the top requested URLs +SELECT cs_uri_stem, COUNT(*) as request_count +FROM cloudfront_logs +WHERE year='2023' AND month='03' AND day='15' +GROUP BY cs_uri_stem +ORDER BY request_count DESC +LIMIT 10; +``` + +## Troubleshooting + +### Common Issues + +1. **Logs not appearing in CloudWatch** + - Check that the CloudFront distribution is receiving traffic + - Verify the IAM permissions for the log delivery service + - Check CloudWatch service quotas if you have high traffic volumes + +2. **Parquet files not appearing in S3** + - Verify bucket permissions allow the log delivery service to write + - Check for any errors in CloudTrail related to log delivery + +3. **Firehose delivery errors** + - Check the Firehose error prefix in S3 for error logs + - Verify IAM role permissions for Firehose + - Monitor Firehose metrics in CloudWatch + +### Useful Commands + +- Check CloudFront distribution status: + ```bash + aws cloudfront get-distribution --id + ``` + +- List log files in S3: + ```bash + aws s3 ls s3://your-logging-bucket/s3_delivery/ --recursive + ``` + +- View CloudWatch logs: + ```bash + aws logs get-log-events --log-group-name --log-stream-name + ``` + +## Cleanup + +To avoid incurring charges, delete the deployed resources when you're done: + +```bash +cdk destroy +``` + +## Security Considerations + +This example includes several security best practices: + +- S3 buckets are configured with encryption, SSL enforcement, and public access blocking +- CloudFront uses Origin Access Control (OAC) to secure S3 content +- IAM permissions follow the principle of least privilege +- Logging bucket has appropriate lifecycle policies to manage log retention diff --git a/python/cloudfront-v2-logging/app.py b/python/cloudfront-v2-logging/app.py new file mode 100644 index 0000000000..bf69778ea2 --- /dev/null +++ b/python/cloudfront-v2-logging/app.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import os +import aws_cdk as cdk +from aws_cdk import Aspects +from cdk_nag import AwsSolutionsChecks, NagSuppressions + +from cloudfront_v2_logging.cloudfront_v2_logging_stack import CloudfrontV2LoggingStack + +app = cdk.App() +stack = CloudfrontV2LoggingStack(app, "CloudfrontV2LoggingStack") + +# Add CDK-NAG to check for best practices +Aspects.of(app).add(AwsSolutionsChecks()) + +# Add suppressions at the stack level +NagSuppressions.add_stack_suppressions( + stack, + [ + { + "id": "AwsSolutions-IAM4", + "reason": "Suppressing managed policy warning as permissions are appropriate" + }, + { + "id": "AwsSolutions-L1", + "reason": "Lambda runtime is 3.11 and managed by CDK BucketDeployment construct, and so out of scope for this project" + }, + { + "id": "AwsSolutions-CFR1", + "reason": "Geo restrictions not required for this demo" + }, + { + "id": "AwsSolutions-CFR2", + "reason": "WAF integration not required for this demo" + }, + { + "id": "AwsSolutions-CFR3", + "reason": "Using CloudFront V2 logging instead of traditional access logging" + }, + { + "id": "AwsSolutions-S1", + "reason": "S3 access logging not required for this demo as we're demonstrating CloudFront V2 logging" + }, + { + "id": "AwsSolutions-IAM5", + "reason": "Wildcard permissions are required for PUT actions for the CDK BucketDeployment construct and Firehose role" + }, + { + "id": "AwsSolutions-CFR4", + "reason": "Using TLSv1.2_2021 security policy which is the latest supported version." + } + ] +) + +app.synth() \ No newline at end of file diff --git a/python/cloudfront-v2-logging/architecture.drawio.png b/python/cloudfront-v2-logging/architecture.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..a02082e441fed4890e1ade23e2810c11a2c328d1 GIT binary patch literal 53450 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 L30fgx82JAH)s|0& literal 0 HcmV?d00001 diff --git a/python/cloudfront-v2-logging/cdk.json b/python/cloudfront-v2-logging/cdk.json new file mode 100644 index 0000000000..3f7ab4728b --- /dev/null +++ b/python/cloudfront-v2-logging/cdk.json @@ -0,0 +1,86 @@ +{ + "app": "python3 app.py", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false, + "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": true + } +} diff --git a/python/cloudfront-v2-logging/cloudfront_v2_logging/__init__.py b/python/cloudfront-v2-logging/cloudfront_v2_logging/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/cloudfront-v2-logging/cloudfront_v2_logging/cloudfront_v2_logging_stack.py b/python/cloudfront-v2-logging/cloudfront_v2_logging/cloudfront_v2_logging_stack.py new file mode 100644 index 0000000000..2acf1659d9 --- /dev/null +++ b/python/cloudfront-v2-logging/cloudfront_v2_logging/cloudfront_v2_logging_stack.py @@ -0,0 +1,342 @@ +from aws_cdk import ( + Duration, + Stack, + aws_logs as logs, + aws_cloudfront as cloudfront, + aws_iam as iam, + aws_s3 as s3, + aws_kinesisfirehose as firehose, + aws_s3_deployment as s3_deployment, + RemovalPolicy, + CfnOutput, + CfnParameter, + CfnMapping +) +from constructs import Construct +from cdk_nag import NagSuppressions + +import json + +class CloudfrontV2LoggingStack(Stack): + """ + CloudFront V2 Logging Stack + + This stack demonstrates how to configure CloudFront Standard Logging V2 with multiple + delivery destinations including CloudWatch Logs, S3 (Partitioned Parquet format), and Kinesis + Data Firehose (JSON format). + """ + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + # CloudFormation parameters for customization + log_retention_days = CfnParameter( + self, "LogRetentionDays", + type="Number", + default=30, + min_value=1, + max_value=365, + description="Number of days to retain CloudFront logs in S3" + ) + + cloudwatch_log_retention_days = CfnParameter( + self, "CloudWatchLogRetentionDays", + type="Number", + default=30, + description="Number of days to retain CloudFront logs in CloudWatch Logs", + allowed_values=[ + "1", "3", "5", "7", "14", "30", "60", "90", + "120", "150", "180", "365", "400", "545", "731", + "1827", "3653", "0" + ] + ) + + # Create the logging bucket for CloudFront + # This bucket will store logs in Parquet format and from Firehose + logging_bucket = s3.Bucket( + self, "CFLoggingBucket", + removal_policy=RemovalPolicy.DESTROY, + encryption=s3.BucketEncryption.S3_MANAGED, + block_public_access=s3.BlockPublicAccess.BLOCK_ALL, + auto_delete_objects=True, + object_ownership=s3.ObjectOwnership.OBJECT_WRITER, # Enable ACLs for log delivery + enforce_ssl=True, + lifecycle_rules=[ + s3.LifecycleRule( + expiration=Duration.days(log_retention_days.value_as_number), # Configurable log retention + ) + ] + ) + + # Create the main S3 bucket for your application + # This bucket will host the static website content + main_bucket = s3.Bucket( + self, "OriginBucket", + removal_policy=RemovalPolicy.DESTROY, + encryption=s3.BucketEncryption.S3_MANAGED, + block_public_access=s3.BlockPublicAccess.BLOCK_ALL, + enforce_ssl=True, # Enforce SSL for all requests + auto_delete_objects=True # Clean up objects when stack is deleted + ) + + # Deploy the static website content to the S3 bucket + s3_deploy = s3_deployment.BucketDeployment( + self, "DeployWebsite", + sources=[s3_deployment.Source.asset("website")], # Directory containing your website files + destination_bucket=main_bucket + ) + + # Add bucket policy to deny direct access to S3 objects + # This ensures content is only accessed through CloudFront + main_bucket.add_to_resource_policy( + iam.PolicyStatement( + effect=iam.Effect.DENY, + actions=["s3:GetObject"], + principals=[iam.AnyPrincipal()], + resources=[main_bucket.arn_for_objects("*")], + conditions={ + "StringNotEquals": { + "aws:PrincipalServiceName": "cloudfront.amazonaws.com" + } + } + ) + ) + + # Grant CloudFront permission to write logs to the S3 bucket + cloudfront_distribution_arn = Stack.of(self).format_arn( + service="cloudfront", + region="", # CloudFront is a global service + resource="distribution", + resource_name="*" # Wildcard for all distributions in the account + ) + + logging_bucket.add_to_resource_policy( + iam.PolicyStatement( + sid="AllowCloudFrontLogDelivery", + actions=["s3:PutObject"], + principals=[iam.ServicePrincipal("delivery.logs.amazonaws.com")], + resources=[f"{logging_bucket.bucket_arn}/*"] + ) + ) + + # Add GetBucketAcl permission required by the log delivery service + logging_bucket.add_to_resource_policy( + iam.PolicyStatement( + sid="AllowCloudFrontLogDeliveryAcl", + actions=["s3:GetBucketAcl"], + principals=[iam.ServicePrincipal("delivery.logs.amazonaws.com")], + resources=[logging_bucket.bucket_arn] + ) + ) + + # Create Origin Access Control for CloudFront to access S3 + # This is the recommended approach instead of Origin Access Identity (OAI) + cloudfront_oac = cloudfront.CfnOriginAccessControl( + self, "CloudFrontOAC", + origin_access_control_config=cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty( + name="S3OAC", + origin_access_control_origin_type="s3", + signing_behavior="always", + signing_protocol="sigv4" + ) + ) + + # Configure the S3 origin for CloudFront + s3_origin_config = cloudfront.CfnDistribution.OriginProperty( + domain_name=main_bucket.bucket_regional_domain_name, + id="S3Origin", + s3_origin_config=cloudfront.CfnDistribution.S3OriginConfigProperty( + origin_access_identity="" + ), + origin_access_control_id=cloudfront_oac.ref + ) + + # Create CloudFront distribution to serve the website + distribution = cloudfront.CfnDistribution( + self, "LoggedDistribution", + distribution_config=cloudfront.CfnDistribution.DistributionConfigProperty( + enabled=True, + default_root_object="index.html", + origins=[s3_origin_config], + default_cache_behavior=cloudfront.CfnDistribution.DefaultCacheBehaviorProperty( + target_origin_id="S3Origin", + viewer_protocol_policy="redirect-to-https", + cache_policy_id="658327ea-f89d-4fab-a63d-7e88639e58f6", # CachingOptimized policy ID + compress=True + ), + viewer_certificate=cloudfront.CfnDistribution.ViewerCertificateProperty( + cloud_front_default_certificate=True, + minimum_protocol_version="TLSv1.2_2021", + ), + http_version="http2" + ) + ) + + # Add bucket policy to allow CloudFront access to S3 objects + main_bucket.add_to_resource_policy( + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=["s3:GetObject"], + principals=[iam.ServicePrincipal("cloudfront.amazonaws.com")], + resources=[main_bucket.arn_for_objects("*")], + conditions={ + "StringEquals": { + "AWS:SourceArn": f"arn:aws:cloudfront::{self.account}:distribution/{distribution.ref}" + } + } + ) + ) + + # SECTION: CLOUDFRONT STANDARD LOGGING V2 CONFIGURATION + + # 1. Create the delivery source for CloudFront distribution logs + # This defines the CloudFront distribution as the source of logs + distribution_delivery_source = logs.CfnDeliverySource( + self, + "DistributionDeliverySource", + name="distribution-source", + log_type="ACCESS_LOGS", + resource_arn=Stack.of(self).format_arn( + service="cloudfront", + region="", # CloudFront is a global service + resource="distribution", + resource_name=distribution.attr_id + ) + ) + + # 2. CLOUDWATCH LOGS DESTINATION + # Create a CloudWatch Logs group + # First, create the log group without specifying retention + log_group = logs.LogGroup( + self, + "DistributionLogGroup" + ) + + # Convert the CloudWatch log retention parameter to RetentionDays enum + cfn_log_group = log_group.node.default_child + cfn_log_group.add_property_override( + "RetentionInDays", + cloudwatch_log_retention_days.value_as_number + ) + + # Create a CloudWatch delivery destination + cf_distribution_delivery_destination = logs.CfnDeliveryDestination( + self, + "CloudWatchDeliveryDestination", + name="cloudwatch-destination", + destination_resource_arn=log_group.log_group_arn, + output_format="json" + ) + + # Create the CloudWatch Logs delivery configuration + cf_delivery = logs.CfnDelivery( + self, + "CloudwatchDelivery", + delivery_source_name=distribution_delivery_source.name, + delivery_destination_arn=cf_distribution_delivery_destination.attr_arn + ) + cf_delivery.node.add_dependency(distribution_delivery_source) + + # 3. S3 PARQUET DESTINATION + # Configure S3 as a delivery destination with Parquet format + s3_distribution_delivery_destination = logs.CfnDeliveryDestination( + self, + "S3DeliveryDestination", + name="s3-destination", + destination_resource_arn=logging_bucket.bucket_arn, + output_format="parquet", + ) + + # Create the S3 delivery configuration with Hive-compatible paths + s3_delivery = logs.CfnDelivery( + self, + "S3Delivery", + delivery_source_name=distribution_delivery_source.name, + delivery_destination_arn=s3_distribution_delivery_destination.attr_arn, + s3_enable_hive_compatible_path=True, # Enable Hive-compatible paths for Athena + s3_suffix_path="s3_delivery/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}" + ) + s3_delivery.node.add_dependency(distribution_delivery_source) + + # 4. KINESIS DATA FIREHOSE DESTINATION + # Create IAM role for Kinesis Firehose with permissions to write to S3 + firehose_role = iam.Role( + self, "FirehoseRole", + assumed_by=iam.ServicePrincipal("firehose.amazonaws.com") + ) + + # Add required permissions for Firehose to write to S3 + firehose_role.add_to_policy(iam.PolicyStatement( + actions=[ + "s3:AbortMultipartUpload", + "s3:GetBucketLocation", + "s3:GetObject", + "s3:ListBucket", + "s3:ListBucketMultipartUploads", + "s3:PutObject" + ], + resources=[ + logging_bucket.bucket_arn, + f"{logging_bucket.bucket_arn}/*" + ] + )) + + # Create Kinesis Firehose delivery stream to buffer and deliver logs to S3 + firehose_stream = firehose.CfnDeliveryStream( + self, "LoggingFirehose", + delivery_stream_name="cloudfront-logs-stream", + delivery_stream_type="DirectPut", + s3_destination_configuration=firehose.CfnDeliveryStream.S3DestinationConfigurationProperty( + bucket_arn=logging_bucket.bucket_arn, + role_arn=firehose_role.role_arn, + buffering_hints=firehose.CfnDeliveryStream.BufferingHintsProperty( + interval_in_seconds=300, # Buffer for 5 minutes + size_in_m_bs=5 # Or until 5MB is reached + ), + compression_format="HADOOP_SNAPPY", # Compress data for efficiency + prefix="firehose_delivery/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/", + error_output_prefix="errors/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/!{firehose:error-output-type}/" + ), + delivery_stream_encryption_configuration_input=firehose.CfnDeliveryStream.DeliveryStreamEncryptionConfigurationInputProperty( + key_type="AWS_OWNED_CMK" + ) + ) + + # Configure Firehose as a delivery destination for CloudFront logs + firehose_delivery_destination = logs.CfnDeliveryDestination( + self, "FirehoseDeliveryDestination", + name="cloudfront-logs-destination", + destination_resource_arn=firehose_stream.attr_arn, + output_format="json" + ) + + # Create the Firehose delivery configuration + delivery = logs.CfnDelivery( + self, + "KinesisDelivery", + delivery_source_name=distribution_delivery_source.name, + delivery_destination_arn=firehose_delivery_destination.attr_arn + ) + delivery.node.add_dependency(distribution_delivery_source) + + # Output the CloudFront distribution domain name for easy access + CfnOutput( + self, "DistributionDomainName", + value=distribution.attr_domain_name, + description="CloudFront distribution domain name" + ) + + # Output the S3 bucket name where logs are stored + CfnOutput( + self, "LoggingBucketName", + value=logging_bucket.bucket_name, + description="S3 bucket for CloudFront logs" + ) + + # Output the CloudWatch log group name and retention period + CfnOutput( + self, "CloudWatchLogGroupName", + value=f"{log_group.log_group_name} (retention: {cloudwatch_log_retention_days.value_as_number} days)", + description="CloudWatch log group for CloudFront logs" + ) + \ No newline at end of file diff --git a/python/cloudfront-v2-logging/requirements.txt b/python/cloudfront-v2-logging/requirements.txt new file mode 100644 index 0000000000..2b512f13cd --- /dev/null +++ b/python/cloudfront-v2-logging/requirements.txt @@ -0,0 +1,3 @@ +aws-cdk-lib==2.180.0 +constructs>=10.0.0,<11.0.0 +cdk-nag>=2.0.0 diff --git a/python/cloudfront-v2-logging/source.bat b/python/cloudfront-v2-logging/source.bat new file mode 100644 index 0000000000..9e1a83442a --- /dev/null +++ b/python/cloudfront-v2-logging/source.bat @@ -0,0 +1,13 @@ +@echo off + +rem The sole purpose of this script is to make the command +rem +rem source .venv/bin/activate +rem +rem (which activates a Python virtualenv on Linux or Mac OS X) work on Windows. +rem On Windows, this command just runs this batch file (the argument is ignored). +rem +rem Now we don't need to document a Windows command for activating a virtualenv. + +echo Executing .venv\Scripts\activate.bat for you +.venv\Scripts\activate.bat diff --git a/python/cloudfront-v2-logging/website/index.html b/python/cloudfront-v2-logging/website/index.html new file mode 100644 index 0000000000..82107655b8 --- /dev/null +++ b/python/cloudfront-v2-logging/website/index.html @@ -0,0 +1,10 @@ + + + + Welcome + + +

Welcome to my CloudFront-enabled website!

+

This is a simple page served via CloudFront from S3. Requests to this distribution will be logged to multiple outputs via CloudFront Standard Logging V2

+ + \ No newline at end of file From 7a67a247b2540dceab6919e6c83177ef1b0a13e0 Mon Sep 17 00:00:00 2001 From: Chris Fane Date: Tue, 12 Aug 2025 16:05:13 +0100 Subject: [PATCH 2/4] Updated to use higher level constructs where possible. --- python/cloudfront-v2-logging/app.py | 2 +- .../cloudfront_v2_logging_stack.py | 246 +++++++----------- 2 files changed, 97 insertions(+), 151 deletions(-) diff --git a/python/cloudfront-v2-logging/app.py b/python/cloudfront-v2-logging/app.py index bf69778ea2..35c1719f61 100644 --- a/python/cloudfront-v2-logging/app.py +++ b/python/cloudfront-v2-logging/app.py @@ -46,7 +46,7 @@ }, { "id": "AwsSolutions-CFR4", - "reason": "Using TLSv1.2_2021 security policy which is the latest supported version." + "reason": "We're making use of the highest currently available viewer certificate. This flag is due to our use of the default viewer certificate which is not an issue in this demonstration case." } ] ) diff --git a/python/cloudfront-v2-logging/cloudfront_v2_logging/cloudfront_v2_logging_stack.py b/python/cloudfront-v2-logging/cloudfront_v2_logging/cloudfront_v2_logging_stack.py index 2acf1659d9..153d4cd583 100644 --- a/python/cloudfront-v2-logging/cloudfront_v2_logging/cloudfront_v2_logging_stack.py +++ b/python/cloudfront-v2-logging/cloudfront_v2_logging/cloudfront_v2_logging_stack.py @@ -1,8 +1,10 @@ from aws_cdk import ( Duration, Stack, + Size, aws_logs as logs, aws_cloudfront as cloudfront, + aws_cloudfront_origins as origins, aws_iam as iam, aws_s3 as s3, aws_kinesisfirehose as firehose, @@ -10,12 +12,10 @@ RemovalPolicy, CfnOutput, CfnParameter, - CfnMapping ) +# Import the destinations module from aws-cdk-lib +from aws_cdk.aws_kinesisfirehose import S3Bucket, Compression from constructs import Construct -from cdk_nag import NagSuppressions - -import json class CloudfrontV2LoggingStack(Stack): """ @@ -30,7 +30,7 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: super().__init__(scope, construct_id, **kwargs) # CloudFormation parameters for customization - log_retention_days = CfnParameter( + s3_log_retention_days = CfnParameter( self, "LogRetentionDays", type="Number", default=30, @@ -51,8 +51,8 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: ] ) - # Create the logging bucket for CloudFront - # This bucket will store logs in Parquet format and from Firehose + # Create the S3 logging bucket for CloudFront + # This bucket will store logs from the S3 output in Parquet format and also be the target for our Firehose delivery logging_bucket = s3.Bucket( self, "CFLoggingBucket", removal_policy=RemovalPolicy.DESTROY, @@ -63,7 +63,7 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: enforce_ssl=True, lifecycle_rules=[ s3.LifecycleRule( - expiration=Duration.days(log_retention_days.value_as_number), # Configurable log retention + expiration=Duration.days(s3_log_retention_days.value_as_number), # Configurable log retention ) ] ) @@ -79,43 +79,54 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: auto_delete_objects=True # Clean up objects when stack is deleted ) - # Deploy the static website content to the S3 bucket - s3_deploy = s3_deployment.BucketDeployment( + # Deploy the static website content to the S3 bucket with improved options + s3_deployment.BucketDeployment( self, "DeployWebsite", sources=[s3_deployment.Source.asset("website")], # Directory containing your website files - destination_bucket=main_bucket + destination_bucket=main_bucket, + content_type="text/html", # Set content type for HTML files + cache_control=[s3_deployment.CacheControl.max_age(Duration.days(7))], # Cache for 7 days + prune=False ) - # Add bucket policy to deny direct access to S3 objects - # This ensures content is only accessed through CloudFront - main_bucket.add_to_resource_policy( - iam.PolicyStatement( - effect=iam.Effect.DENY, - actions=["s3:GetObject"], - principals=[iam.AnyPrincipal()], - resources=[main_bucket.arn_for_objects("*")], - conditions={ - "StringNotEquals": { - "aws:PrincipalServiceName": "cloudfront.amazonaws.com" - } - } - ) + # Create CloudWatch Logs group with configurable retention + log_group = logs.LogGroup( + self, + "DistributionLogGroup", + retention=self._get_log_retention(cloudwatch_log_retention_days.value_as_number) ) - - # Grant CloudFront permission to write logs to the S3 bucket - cloudfront_distribution_arn = Stack.of(self).format_arn( - service="cloudfront", - region="", # CloudFront is a global service - resource="distribution", - resource_name="*" # Wildcard for all distributions in the account + + # Create Kinesis Firehose delivery stream to buffer and deliver logs to S3 using L2 construct + # Define S3 destination for Firehose with dynamic prefixes + s3_destination = S3Bucket( + bucket=logging_bucket, + data_output_prefix="firehose_delivery/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/", + error_output_prefix="errors/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/!{firehose:error-output-type}/", + buffering_interval=Duration.seconds(300), # Buffer for 5 minutes + buffering_size=Size.mebibytes(5), # Or until 5MB is reached + compression=Compression.HADOOP_SNAPPY # Compress data for efficiency ) + # Create Kinesis Firehose delivery stream using L2 construct + firehose_stream = firehose.DeliveryStream( + self, "LoggingFirehose", + delivery_stream_name="cloudfront-logs-stream", + destination=s3_destination, + encryption=firehose.StreamEncryption.aws_owned_key() + ) + + # Grant permissions for the delivery service to write logs to the S3 bucket logging_bucket.add_to_resource_policy( iam.PolicyStatement( sid="AllowCloudFrontLogDelivery", actions=["s3:PutObject"], principals=[iam.ServicePrincipal("delivery.logs.amazonaws.com")], - resources=[f"{logging_bucket.bucket_arn}/*"] + resources=[f"{logging_bucket.bucket_arn}/*"], + conditions={ + "StringEquals": { + "aws:SourceAccount": Stack.of(self).account + } + } ) ) @@ -125,68 +136,31 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: sid="AllowCloudFrontLogDeliveryAcl", actions=["s3:GetBucketAcl"], principals=[iam.ServicePrincipal("delivery.logs.amazonaws.com")], - resources=[logging_bucket.bucket_arn] - ) - ) - - # Create Origin Access Control for CloudFront to access S3 - # This is the recommended approach instead of Origin Access Identity (OAI) - cloudfront_oac = cloudfront.CfnOriginAccessControl( - self, "CloudFrontOAC", - origin_access_control_config=cloudfront.CfnOriginAccessControl.OriginAccessControlConfigProperty( - name="S3OAC", - origin_access_control_origin_type="s3", - signing_behavior="always", - signing_protocol="sigv4" - ) - ) - - # Configure the S3 origin for CloudFront - s3_origin_config = cloudfront.CfnDistribution.OriginProperty( - domain_name=main_bucket.bucket_regional_domain_name, - id="S3Origin", - s3_origin_config=cloudfront.CfnDistribution.S3OriginConfigProperty( - origin_access_identity="" - ), - origin_access_control_id=cloudfront_oac.ref - ) - - # Create CloudFront distribution to serve the website - distribution = cloudfront.CfnDistribution( - self, "LoggedDistribution", - distribution_config=cloudfront.CfnDistribution.DistributionConfigProperty( - enabled=True, - default_root_object="index.html", - origins=[s3_origin_config], - default_cache_behavior=cloudfront.CfnDistribution.DefaultCacheBehaviorProperty( - target_origin_id="S3Origin", - viewer_protocol_policy="redirect-to-https", - cache_policy_id="658327ea-f89d-4fab-a63d-7e88639e58f6", # CachingOptimized policy ID - compress=True - ), - viewer_certificate=cloudfront.CfnDistribution.ViewerCertificateProperty( - cloud_front_default_certificate=True, - minimum_protocol_version="TLSv1.2_2021", - ), - http_version="http2" - ) - ) - - # Add bucket policy to allow CloudFront access to S3 objects - main_bucket.add_to_resource_policy( - iam.PolicyStatement( - effect=iam.Effect.ALLOW, - actions=["s3:GetObject"], - principals=[iam.ServicePrincipal("cloudfront.amazonaws.com")], - resources=[main_bucket.arn_for_objects("*")], + resources=[logging_bucket.bucket_arn], conditions={ "StringEquals": { - "AWS:SourceArn": f"arn:aws:cloudfront::{self.account}:distribution/{distribution.ref}" + "aws:SourceAccount": Stack.of(self).account } } ) ) + # Create CloudFront distribution with S3BucketOrigin + distribution = cloudfront.Distribution( + self, "LoggedDistribution", + comment="CloudFront distribution with STD Logging V2 Configuration Examples", + default_behavior=cloudfront.BehaviorOptions( + origin=origins.S3BucketOrigin.with_origin_access_control(main_bucket), + viewer_protocol_policy=cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + cache_policy=cloudfront.CachePolicy.CACHING_OPTIMIZED, + compress=True + ), + default_root_object="index.html", + minimum_protocol_version=cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021, # Uses TLS 1.2 as minimum + http_version=cloudfront.HttpVersion.HTTP2, + enable_logging=False # We're using CloudFront V2 logging instead of traditional logging + ) + # SECTION: CLOUDFRONT STANDARD LOGGING V2 CONFIGURATION # 1. Create the delivery source for CloudFront distribution logs @@ -200,30 +174,17 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: service="cloudfront", region="", # CloudFront is a global service resource="distribution", - resource_name=distribution.attr_id + resource_name=distribution.distribution_id ) ) # 2. CLOUDWATCH LOGS DESTINATION - # Create a CloudWatch Logs group - # First, create the log group without specifying retention - log_group = logs.LogGroup( - self, - "DistributionLogGroup" - ) - - # Convert the CloudWatch log retention parameter to RetentionDays enum - cfn_log_group = log_group.node.default_child - cfn_log_group.add_property_override( - "RetentionInDays", - cloudwatch_log_retention_days.value_as_number - ) # Create a CloudWatch delivery destination cf_distribution_delivery_destination = logs.CfnDeliveryDestination( self, "CloudWatchDeliveryDestination", - name="cloudwatch-destination", + name="cloudwatch-logs-destination", destination_resource_arn=log_group.log_group_arn, output_format="json" ) @@ -236,6 +197,7 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: delivery_destination_arn=cf_distribution_delivery_destination.attr_arn ) cf_delivery.node.add_dependency(distribution_delivery_source) + cf_delivery.node.add_dependency(cf_distribution_delivery_destination) # 3. S3 PARQUET DESTINATION # Configure S3 as a delivery destination with Parquet format @@ -257,72 +219,33 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: s3_suffix_path="s3_delivery/{DistributionId}/{yyyy}/{MM}/{dd}/{HH}" ) s3_delivery.node.add_dependency(distribution_delivery_source) + s3_delivery.node.add_dependency(s3_distribution_delivery_destination) + s3_delivery.node.add_dependency(cf_delivery) # Make S3 delivery depend on CloudWatch delivery # 4. KINESIS DATA FIREHOSE DESTINATION - # Create IAM role for Kinesis Firehose with permissions to write to S3 - firehose_role = iam.Role( - self, "FirehoseRole", - assumed_by=iam.ServicePrincipal("firehose.amazonaws.com") - ) - - # Add required permissions for Firehose to write to S3 - firehose_role.add_to_policy(iam.PolicyStatement( - actions=[ - "s3:AbortMultipartUpload", - "s3:GetBucketLocation", - "s3:GetObject", - "s3:ListBucket", - "s3:ListBucketMultipartUploads", - "s3:PutObject" - ], - resources=[ - logging_bucket.bucket_arn, - f"{logging_bucket.bucket_arn}/*" - ] - )) - - # Create Kinesis Firehose delivery stream to buffer and deliver logs to S3 - firehose_stream = firehose.CfnDeliveryStream( - self, "LoggingFirehose", - delivery_stream_name="cloudfront-logs-stream", - delivery_stream_type="DirectPut", - s3_destination_configuration=firehose.CfnDeliveryStream.S3DestinationConfigurationProperty( - bucket_arn=logging_bucket.bucket_arn, - role_arn=firehose_role.role_arn, - buffering_hints=firehose.CfnDeliveryStream.BufferingHintsProperty( - interval_in_seconds=300, # Buffer for 5 minutes - size_in_m_bs=5 # Or until 5MB is reached - ), - compression_format="HADOOP_SNAPPY", # Compress data for efficiency - prefix="firehose_delivery/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/", - error_output_prefix="errors/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/!{firehose:error-output-type}/" - ), - delivery_stream_encryption_configuration_input=firehose.CfnDeliveryStream.DeliveryStreamEncryptionConfigurationInputProperty( - key_type="AWS_OWNED_CMK" - ) - ) - # Configure Firehose as a delivery destination for CloudFront logs firehose_delivery_destination = logs.CfnDeliveryDestination( self, "FirehoseDeliveryDestination", name="cloudfront-logs-destination", - destination_resource_arn=firehose_stream.attr_arn, + destination_resource_arn=firehose_stream.delivery_stream_arn, output_format="json" ) # Create the Firehose delivery configuration delivery = logs.CfnDelivery( self, - "KinesisDelivery", + "Delivery", delivery_source_name=distribution_delivery_source.name, delivery_destination_arn=firehose_delivery_destination.attr_arn ) delivery.node.add_dependency(distribution_delivery_source) + delivery.node.add_dependency(firehose_delivery_destination) + delivery.node.add_dependency(s3_delivery) # Make Firehose delivery depend on S3 delivery # Output the CloudFront distribution domain name for easy access CfnOutput( self, "DistributionDomainName", - value=distribution.attr_domain_name, + value=distribution.distribution_domain_name, description="CloudFront distribution domain name" ) @@ -339,4 +262,27 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: value=f"{log_group.log_group_name} (retention: {cloudwatch_log_retention_days.value_as_number} days)", description="CloudWatch log group for CloudFront logs" ) - \ No newline at end of file + + def _get_log_retention(self, days): + """Convert numeric days to logs.RetentionDays enum value""" + retention_map = { + 0: logs.RetentionDays.INFINITE, + 1: logs.RetentionDays.ONE_DAY, + 3: logs.RetentionDays.THREE_DAYS, + 5: logs.RetentionDays.FIVE_DAYS, + 7: logs.RetentionDays.ONE_WEEK, + 14: logs.RetentionDays.TWO_WEEKS, + 30: logs.RetentionDays.ONE_MONTH, + 60: logs.RetentionDays.TWO_MONTHS, + 90: logs.RetentionDays.THREE_MONTHS, + 120: logs.RetentionDays.FOUR_MONTHS, + 150: logs.RetentionDays.FIVE_MONTHS, + 180: logs.RetentionDays.SIX_MONTHS, + 365: logs.RetentionDays.ONE_YEAR, + 400: logs.RetentionDays.THIRTEEN_MONTHS, + 545: logs.RetentionDays.EIGHTEEN_MONTHS, + 731: logs.RetentionDays.TWO_YEARS, + 1827: logs.RetentionDays.FIVE_YEARS, + 3653: logs.RetentionDays.TEN_YEARS + } + return retention_map.get(int(days), logs.RetentionDays.ONE_MONTH) From 02c7d4aa98a680962adbedc10f8f1fc88f1c44f2 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Thu, 21 Aug 2025 08:28:45 -0500 Subject: [PATCH 3/4] Delete python/cloudfront-v2-logging/source.bat --- python/cloudfront-v2-logging/source.bat | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 python/cloudfront-v2-logging/source.bat diff --git a/python/cloudfront-v2-logging/source.bat b/python/cloudfront-v2-logging/source.bat deleted file mode 100644 index 9e1a83442a..0000000000 --- a/python/cloudfront-v2-logging/source.bat +++ /dev/null @@ -1,13 +0,0 @@ -@echo off - -rem The sole purpose of this script is to make the command -rem -rem source .venv/bin/activate -rem -rem (which activates a Python virtualenv on Linux or Mac OS X) work on Windows. -rem On Windows, this command just runs this batch file (the argument is ignored). -rem -rem Now we don't need to document a Windows command for activating a virtualenv. - -echo Executing .venv\Scripts\activate.bat for you -.venv\Scripts\activate.bat From fbc8e6342062a3fc79ab16bf20213ed5fcea6be0 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Thu, 21 Aug 2025 08:29:11 -0500 Subject: [PATCH 4/4] Update requirements.txt --- python/cloudfront-v2-logging/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/cloudfront-v2-logging/requirements.txt b/python/cloudfront-v2-logging/requirements.txt index 2b512f13cd..75a8f6ffc4 100644 --- a/python/cloudfront-v2-logging/requirements.txt +++ b/python/cloudfront-v2-logging/requirements.txt @@ -1,3 +1,3 @@ -aws-cdk-lib==2.180.0 +aws-cdk-lib==2.211.0 constructs>=10.0.0,<11.0.0 cdk-nag>=2.0.0