Skip to content

Commit 39a44f2

Browse files
committed
Add optional bucket/report creation and SNS support to source module
- Add create_bucket toggle to skip S3 bucket creation for existing buckets - Add create_report toggle to skip CUR report definition for existing reports - Add use_sns option for SNS-based notifications instead of direct Lambda - Add sns_topic_name, sns_subscriber_arns, cur_s3_prefix variables - Add sns_topic_arn output - Update README with new inputs/outputs and usage example
1 parent 90743c7 commit 39a44f2

File tree

4 files changed

+280
-74
lines changed

4 files changed

+280
-74
lines changed

README.md

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

33
Multi-account AWS Cost and Usage Report (CUR) aggregation and analysis. This module provides two submodules:
44

5-
- **`modules/source`** - Deployed in each source AWS account. Creates CUR report definition, S3 bucket, and event notification to forward reports to the target account.
5+
- **`modules/source`** - Deployed in each source AWS account. Optionally creates CUR report definition, S3 bucket, and event notification (direct Lambda or SNS) to forward reports to the target account.
66
- **`modules/target`** - Deployed in the central/target AWS account. Aggregates CUR reports from multiple source accounts using a Lambda function, with optional Athena/Glue analysis and IAM access management.
77

88
## Architecture
99

1010
```
1111
┌─────────────────────┐ ┌─────────────────────┐
12-
│ Source Account A │ │ Source Account B
12+
│ Source Account A │ │ Source Account B │
1313
│ │ │ │
1414
│ CUR Report → S3 │ │ CUR Report → S3 │
1515
│ │ │ │ │ │
@@ -74,9 +74,34 @@ module "cur_source" {
7474
}
7575
```
7676

77+
### 3. Source module with existing bucket and SNS
78+
79+
```terraform
80+
module "cur_source" {
81+
source = "cookielab/cost-reporting/aws//modules/source"
82+
83+
providers = {
84+
aws.us_east_1 = aws.us_east_1
85+
}
86+
87+
create_bucket = false
88+
create_report = false
89+
s3_bucket_name = "my-existing-cur-bucket"
90+
91+
use_sns = true
92+
sns_subscriber_arns = ["arn:aws:iam::000000000000:root"]
93+
94+
lambda_function_role_arn = "arn:aws:iam::000000000000:role/cur-forwarder-role"
95+
}
96+
```
97+
7798
## Source Module
7899

79-
Creates AWS CUR report definition and S3 bucket in a source account, with cross-account access for the target Lambda.
100+
Creates AWS CUR report definition and S3 bucket in a source account, with cross-account access for the target Lambda. Both bucket and report creation are optional for accounts that already have them configured.
101+
102+
Supports two notification modes:
103+
- **Direct Lambda** (default) - S3 event triggers Lambda directly
104+
- **SNS** (`use_sns = true`) - S3 event publishes to SNS topic, target Lambda subscribes
80105

81106
### Requirements
82107

@@ -89,13 +114,19 @@ Creates AWS CUR report definition and S3 bucket in a source account, with cross-
89114

90115
| Name | Description | Type | Default | Required |
91116
|------|-------------|------|---------|:--------:|
92-
| lambda_function_arn | ARN of the Lambda function in the target account | `string` | n/a | yes |
117+
| create_bucket | Whether to create a new S3 bucket or use existing | `bool` | `true` | no |
118+
| create_report | Whether to create a new CUR report definition | `bool` | `true` | no |
119+
| lambda_function_arn | ARN of the Lambda function (required when `use_sns = false`) | `string` | `""` | no |
93120
| lambda_function_role_arn | ARN of the IAM role for the Lambda function (for bucket policy) | `string` | n/a | yes |
121+
| use_sns | Use SNS topic instead of direct Lambda invocation | `bool` | `false` | no |
122+
| sns_topic_name | SNS topic name (defaults to `cur-notifications-{account_id}`) | `string` | `null` | no |
123+
| sns_subscriber_arns | ARNs allowed to subscribe to the SNS topic | `list(string)` | `[]` | no |
94124
| s3_bucket_name | S3 bucket name (defaults to `cur-csv-{account_id}`) | `string` | `null` | no |
125+
| s3_bucket_lifecycle | S3 lifecycle transitions (only when `create_bucket = true`) | `object` | `{transition_to_ia_days=90, transition_to_glacier_days=180}` | no |
95126
| cur_time_unit | Time unit for CUR report (`HOURLY` or `DAILY`) | `string` | `"HOURLY"` | no |
96127
| cur_format | Report format (`textORcsv` or `Parquet`) | `string` | `"textORcsv"` | no |
97128
| cur_compression | Compression (`GZIP`, `ZIP`, or `Parquet`) | `string` | `"GZIP"` | no |
98-
| s3_bucket_lifecycle | S3 lifecycle transitions | `object` | `{transition_to_ia_days=90, transition_to_glacier_days=180}` | no |
129+
| cur_s3_prefix | S3 prefix for existing CUR report (only when `create_report = false`) | `string` | `"cur-reports"` | no |
99130
| tags | Tags to apply to resources | `map(string)` | `{}` | no |
100131

101132
### Outputs
@@ -105,9 +136,10 @@ Creates AWS CUR report definition and S3 bucket in a source account, with cross-
105136
| bucket_id | ID of the CUR S3 bucket |
106137
| bucket_arn | ARN of the CUR S3 bucket |
107138
| bucket_name | Name of the CUR S3 bucket |
108-
| cur_report_name | Name of the CUR report |
139+
| cur_report_name | Name of the CUR report (null if `create_report = false`) |
109140
| cur_prefix | S3 prefix where CUR reports are stored |
110141
| account_id | AWS Account ID |
142+
| sns_topic_arn | ARN of the SNS topic (null if `use_sns = false`) |
111143

112144
## Target Module
113145

modules/source/main.tf

Lines changed: 160 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,68 +8,79 @@ data "aws_region" "current" {}
88
locals {
99
bucket_name = coalesce(var.s3_bucket_name, "cur-csv-${data.aws_caller_identity.current.account_id}")
1010
bucket_arn = "arn:aws:s3:::${local.bucket_name}"
11+
bucket_id = var.create_bucket ? module.cur_bucket[0].s3_bucket_id : local.bucket_name
12+
13+
cur_s3_prefix = var.create_report ? aws_cur_report_definition.this[0].s3_prefix : var.cur_s3_prefix
14+
15+
sns_topic_name = coalesce(var.sns_topic_name, "cur-notifications-${data.aws_caller_identity.current.account_id}")
1116
}
1217

1318
# =============================================================================
1419
# IAM Policy Documents
1520
# =============================================================================
1621

17-
# Policy for AWS Billing service to write CUR reports + Lambda to read
22+
# Bucket policy: Billing service write + Lambda cross-account read
1823
data "aws_iam_policy_document" "bucket_policy" {
1924
# Allow AWS Billing service to check bucket ACL
20-
statement {
21-
sid = "AllowBillingGetBucketAcl"
22-
effect = "Allow"
23-
24-
principals {
25-
type = "Service"
26-
identifiers = ["billingreports.amazonaws.com"]
27-
}
25+
dynamic "statement" {
26+
for_each = var.create_report ? [1] : []
27+
content {
28+
sid = "AllowBillingGetBucketAcl"
29+
effect = "Allow"
30+
31+
principals {
32+
type = "Service"
33+
identifiers = ["billingreports.amazonaws.com"]
34+
}
2835

29-
actions = [
30-
"s3:GetBucketAcl",
31-
"s3:GetBucketPolicy"
32-
]
36+
actions = [
37+
"s3:GetBucketAcl",
38+
"s3:GetBucketPolicy"
39+
]
3340

34-
resources = [local.bucket_arn]
41+
resources = [local.bucket_arn]
3542

36-
condition {
37-
test = "StringEquals"
38-
variable = "aws:SourceArn"
39-
values = [aws_cur_report_definition.this.arn]
40-
}
43+
condition {
44+
test = "StringEquals"
45+
variable = "aws:SourceArn"
46+
values = [aws_cur_report_definition.this[0].arn]
47+
}
4148

42-
condition {
43-
test = "StringEquals"
44-
variable = "aws:SourceAccount"
45-
values = [data.aws_caller_identity.current.account_id]
49+
condition {
50+
test = "StringEquals"
51+
variable = "aws:SourceAccount"
52+
values = [data.aws_caller_identity.current.account_id]
53+
}
4654
}
4755
}
4856

4957
# Allow AWS Billing service to write CUR reports
50-
statement {
51-
sid = "AllowBillingPutObject"
52-
effect = "Allow"
53-
54-
principals {
55-
type = "Service"
56-
identifiers = ["billingreports.amazonaws.com"]
57-
}
58+
dynamic "statement" {
59+
for_each = var.create_report ? [1] : []
60+
content {
61+
sid = "AllowBillingPutObject"
62+
effect = "Allow"
63+
64+
principals {
65+
type = "Service"
66+
identifiers = ["billingreports.amazonaws.com"]
67+
}
5868

59-
actions = ["s3:PutObject"]
69+
actions = ["s3:PutObject"]
6070

61-
resources = ["${local.bucket_arn}/*"]
71+
resources = ["${local.bucket_arn}/*"]
6272

63-
condition {
64-
test = "StringEquals"
65-
variable = "aws:SourceArn"
66-
values = [aws_cur_report_definition.this.arn]
67-
}
73+
condition {
74+
test = "StringEquals"
75+
variable = "aws:SourceArn"
76+
values = [aws_cur_report_definition.this[0].arn]
77+
}
6878

69-
condition {
70-
test = "StringEquals"
71-
variable = "aws:SourceAccount"
72-
values = [data.aws_caller_identity.current.account_id]
79+
condition {
80+
test = "StringEquals"
81+
variable = "aws:SourceAccount"
82+
values = [data.aws_caller_identity.current.account_id]
83+
}
7384
}
7485
}
7586

@@ -97,13 +108,15 @@ data "aws_iam_policy_document" "bucket_policy" {
97108
}
98109

99110
# =============================================================================
100-
# S3 Bucket for CUR reports
111+
# S3 Bucket for CUR reports (optional)
101112
# =============================================================================
102113

103114
module "cur_bucket" {
104115
source = "terraform-aws-modules/s3-bucket/aws"
105116
version = "4.11.0"
106117

118+
count = var.create_bucket ? 1 : 0
119+
107120
bucket = local.bucket_name
108121

109122
versioning = {
@@ -168,19 +181,28 @@ module "cur_bucket" {
168181
})
169182
}
170183

184+
# Attach bucket policy to existing bucket (when create_bucket = false)
185+
resource "aws_s3_bucket_policy" "existing" {
186+
count = var.create_bucket ? 0 : 1
187+
188+
bucket = local.bucket_name
189+
policy = data.aws_iam_policy_document.bucket_policy.json
190+
}
191+
171192
# =============================================================================
172-
# CUR Report Definition (must be in us-east-1)
193+
# CUR Report Definition (optional, must be in us-east-1)
173194
# =============================================================================
174195

175196
resource "aws_cur_report_definition" "this" {
197+
count = var.create_report ? 1 : 0
176198
provider = aws.us_east_1
177199

178200
report_name = "${lower(var.cur_time_unit)}-cur-${lower(var.cur_format)}-${data.aws_caller_identity.current.account_id}"
179201
time_unit = var.cur_time_unit
180202
format = var.cur_format
181203
compression = var.cur_compression
182204
additional_schema_elements = ["RESOURCES", "SPLIT_COST_ALLOCATION_DATA"]
183-
s3_bucket = module.cur_bucket.s3_bucket_id
205+
s3_bucket = local.bucket_id
184206
s3_region = data.aws_region.current.name
185207
s3_prefix = "cur-reports"
186208

@@ -191,16 +213,102 @@ resource "aws_cur_report_definition" "this" {
191213
}
192214

193215
# =============================================================================
194-
# S3 Event Notification to invoke Lambda in target account
216+
# SNS Topic for CUR notifications (optional)
217+
# =============================================================================
218+
219+
resource "aws_sns_topic" "cur" {
220+
count = var.use_sns ? 1 : 0
221+
222+
name = local.sns_topic_name
223+
224+
tags = merge(var.tags, {
225+
Name = "CUR Notifications"
226+
Purpose = "S3 event notifications for CUR reports"
227+
})
228+
}
229+
230+
data "aws_iam_policy_document" "sns_topic_policy" {
231+
count = var.use_sns ? 1 : 0
232+
233+
# Allow S3 to publish to SNS
234+
statement {
235+
sid = "AllowS3Publish"
236+
effect = "Allow"
237+
238+
principals {
239+
type = "Service"
240+
identifiers = ["s3.amazonaws.com"]
241+
}
242+
243+
actions = ["SNS:Publish"]
244+
resources = [aws_sns_topic.cur[0].arn]
245+
246+
condition {
247+
test = "ArnLike"
248+
variable = "aws:SourceArn"
249+
values = [local.bucket_arn]
250+
}
251+
252+
condition {
253+
test = "StringEquals"
254+
variable = "aws:SourceAccount"
255+
values = [data.aws_caller_identity.current.account_id]
256+
}
257+
}
258+
259+
# Allow target account Lambda/SQS to subscribe
260+
dynamic "statement" {
261+
for_each = length(var.sns_subscriber_arns) > 0 ? [1] : []
262+
content {
263+
sid = "AllowCrossAccountSubscribe"
264+
effect = "Allow"
265+
266+
principals {
267+
type = "AWS"
268+
identifiers = var.sns_subscriber_arns
269+
}
270+
271+
actions = [
272+
"SNS:Subscribe",
273+
"SNS:Receive"
274+
]
275+
276+
resources = [aws_sns_topic.cur[0].arn]
277+
}
278+
}
279+
}
280+
281+
resource "aws_sns_topic_policy" "cur" {
282+
count = var.use_sns ? 1 : 0
283+
284+
arn = aws_sns_topic.cur[0].arn
285+
policy = data.aws_iam_policy_document.sns_topic_policy[0].json
286+
}
287+
288+
# =============================================================================
289+
# S3 Event Notification
195290
# =============================================================================
196-
# Note: Lambda permission is managed in the target module
197291

198292
resource "aws_s3_bucket_notification" "cur" {
199-
bucket = module.cur_bucket.s3_bucket_id
293+
bucket = local.bucket_id
294+
295+
# Direct Lambda invocation
296+
dynamic "lambda_function" {
297+
for_each = var.use_sns ? [] : [1]
298+
content {
299+
lambda_function_arn = var.lambda_function_arn
300+
events = ["s3:ObjectCreated:*"]
301+
filter_prefix = "${local.cur_s3_prefix}/"
302+
}
303+
}
200304

201-
lambda_function {
202-
lambda_function_arn = var.lambda_function_arn
203-
events = ["s3:ObjectCreated:*"]
204-
filter_prefix = "${aws_cur_report_definition.this.s3_prefix}/"
305+
# SNS notification
306+
dynamic "topic" {
307+
for_each = var.use_sns ? [1] : []
308+
content {
309+
topic_arn = aws_sns_topic.cur[0].arn
310+
events = ["s3:ObjectCreated:*"]
311+
filter_prefix = "${local.cur_s3_prefix}/"
312+
}
205313
}
206314
}

0 commit comments

Comments
 (0)