@everydaydevopsio/hail 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ terraform {
2
+ required_version = ">= 1.7, < 2.0"
3
+ required_providers {
4
+ aws = { source = "hashicorp/aws", version = ">= 5.50, < 7.0" }
5
+ archive = { source = "hashicorp/archive", version = ">= 2.4, < 3.0" }
6
+ }
7
+ }
8
+
9
+ data "aws_caller_identity" "current" {}
10
+ data "aws_partition" "current" {}
11
+ data "aws_region" "current" {}
12
+
13
+ locals {
14
+ bucket_name = coalesce(var.bucket_name, "${var.name}-${data.aws_caller_identity.current.account_id}-${substr(sha256(var.domain), 0, 10)}")
15
+ rule_set_name = var.existing_rule_set_name != null ? var.existing_rule_set_name : "${var.name}-rules"
16
+ rule_name = "${var.name}-receive"
17
+ rule_arn = "arn:${data.aws_partition.current.partition}:ses:${var.region}:${data.aws_caller_identity.current.account_id}:receipt-rule-set/${local.rule_set_name}:receipt-rule/${local.rule_name}"
18
+ tags = merge(var.tags, { ManagedBy = "hail" })
19
+ external_iam = var.external_indexer_role_arn != null && var.external_reader_role_arn != null
20
+ indexer_role_arn = local.external_iam ? var.external_indexer_role_arn : aws_iam_role.indexer[0].arn
21
+ }
22
+
23
+ resource "aws_s3_bucket" "emails" {
24
+ bucket = local.bucket_name
25
+ force_destroy = false
26
+ tags = local.tags
27
+ }
28
+ resource "aws_s3_bucket_public_access_block" "emails" {
29
+ bucket = aws_s3_bucket.emails.id
30
+ block_public_acls = true
31
+ block_public_policy = true
32
+ ignore_public_acls = true
33
+ restrict_public_buckets = true
34
+ }
35
+ resource "aws_s3_bucket_server_side_encryption_configuration" "emails" {
36
+ bucket = aws_s3_bucket.emails.id
37
+ rule {
38
+ apply_server_side_encryption_by_default { sse_algorithm = "AES256" }
39
+ }
40
+ }
41
+ # Versioning is intentionally not enabled for short-lived authentication mail.
42
+ resource "aws_s3_bucket_lifecycle_configuration" "emails" {
43
+ bucket = aws_s3_bucket.emails.id
44
+ rule {
45
+ id = "expire-test-mail"
46
+ status = "Enabled"
47
+ filter { prefix = "" }
48
+ expiration { days = var.retention_days }
49
+ noncurrent_version_expiration { noncurrent_days = var.retention_days }
50
+ abort_incomplete_multipart_upload { days_after_initiation = 1 }
51
+ }
52
+ }
53
+ resource "aws_s3_bucket_policy" "emails" {
54
+ bucket = aws_s3_bucket.emails.id
55
+ policy = jsonencode({ Version = "2012-10-17", Statement = [
56
+ {
57
+ Sid = "AllowSESWrite", Effect = "Allow", Principal = { Service = "ses.amazonaws.com" },
58
+ Action = "s3:PutObject", Resource = "${aws_s3_bucket.emails.arn}/incoming/*",
59
+ Condition = { StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id, "aws:SourceArn" = local.rule_arn } }
60
+ },
61
+ {
62
+ Sid = "DenyInsecureTransport", Effect = "Deny", Principal = "*", Action = "s3:*",
63
+ Resource = [aws_s3_bucket.emails.arn, "${aws_s3_bucket.emails.arn}/*"],
64
+ Condition = { Bool = { "aws:SecureTransport" = "false" } }
65
+ }
66
+ ] })
67
+ }
68
+ resource "aws_sns_topic" "received" {
69
+ name = "${var.name}-received"
70
+ tags = local.tags
71
+ }
72
+ resource "aws_sns_topic_policy" "received" {
73
+ arn = aws_sns_topic.received.arn
74
+ policy = jsonencode({ Version = "2012-10-17", Statement = [{
75
+ Effect = "Allow", Principal = { Service = "ses.amazonaws.com" }, Action = "sns:Publish", Resource = aws_sns_topic.received.arn,
76
+ Condition = { StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id, "aws:SourceArn" = local.rule_arn } }
77
+ }] })
78
+ }
79
+ resource "aws_sqs_queue" "dlq" {
80
+ name = "${var.name}-ingestion-dlq"
81
+ message_retention_seconds = 1209600
82
+ sqs_managed_sse_enabled = true
83
+ tags = local.tags
84
+ }
85
+ resource "aws_sqs_queue" "ingestion" {
86
+ name = "${var.name}-ingestion"
87
+ visibility_timeout_seconds = 180
88
+ message_retention_seconds = 259200
89
+ sqs_managed_sse_enabled = true
90
+ redrive_policy = jsonencode({ deadLetterTargetArn = aws_sqs_queue.dlq.arn, maxReceiveCount = 5 })
91
+ tags = local.tags
92
+ }
93
+ resource "aws_sqs_queue_policy" "ingestion" {
94
+ queue_url = aws_sqs_queue.ingestion.url
95
+ policy = jsonencode({ Version = "2012-10-17", Statement = [{
96
+ Effect = "Allow", Principal = { Service = "sns.amazonaws.com" }, Action = "sqs:SendMessage", Resource = aws_sqs_queue.ingestion.arn,
97
+ Condition = { ArnEquals = { "aws:SourceArn" = aws_sns_topic.received.arn } }
98
+ }] })
99
+ }
100
+ resource "aws_sns_topic_subscription" "ingestion" {
101
+ topic_arn = aws_sns_topic.received.arn
102
+ protocol = "sqs"
103
+ endpoint = aws_sqs_queue.ingestion.arn
104
+ raw_message_delivery = true
105
+ depends_on = [aws_sqs_queue_policy.ingestion]
106
+ }
107
+ resource "aws_cloudwatch_log_group" "indexer" {
108
+ name = "/aws/lambda/${var.name}-indexer"
109
+ retention_in_days = 7
110
+ tags = local.tags
111
+ }
112
+ resource "aws_iam_role" "indexer" {
113
+ count = local.external_iam ? 0 : 1
114
+ name = "${var.name}-indexer"
115
+ permissions_boundary = var.permissions_boundary_arn
116
+ assume_role_policy = jsonencode({ Version = "2012-10-17", Statement = [{
117
+ Effect = "Allow", Principal = { Service = "lambda.amazonaws.com" }, Action = "sts:AssumeRole"
118
+ }] })
119
+ tags = local.tags
120
+ }
121
+ resource "aws_iam_role_policy" "indexer" {
122
+ count = local.external_iam ? 0 : 1
123
+ role = aws_iam_role.indexer[0].id
124
+ policy = jsonencode({ Version = "2012-10-17", Statement = [
125
+ { Effect = "Allow", Action = ["s3:GetObject"], Resource = "${aws_s3_bucket.emails.arn}/incoming/*" },
126
+ { Effect = "Allow", Action = ["s3:PutObject"], Resource = "${aws_s3_bucket.emails.arn}/index/*" },
127
+ { Effect = "Allow", Action = ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"], Resource = aws_sqs_queue.ingestion.arn },
128
+ { Effect = "Allow", Action = ["logs:CreateLogStream", "logs:PutLogEvents"], Resource = "${aws_cloudwatch_log_group.indexer.arn}:*" }
129
+ ] })
130
+ }
131
+ data "archive_file" "indexer" {
132
+ type = "zip"
133
+ source_file = "${path.module}/lambda/handler.py"
134
+ output_path = "${path.module}/lambda.zip"
135
+ }
136
+ resource "aws_lambda_function" "indexer" {
137
+ function_name = "${var.name}-indexer"
138
+ role = local.indexer_role_arn
139
+ runtime = "python3.12"
140
+ handler = "handler.handler"
141
+ filename = data.archive_file.indexer.output_path
142
+ source_code_hash = data.archive_file.indexer.output_base64sha256
143
+ timeout = 30
144
+ memory_size = 128
145
+ environment {
146
+ variables = { BUCKET = aws_s3_bucket.emails.id, DOMAIN = var.domain }
147
+ }
148
+ depends_on = [aws_iam_role_policy.indexer, aws_cloudwatch_log_group.indexer]
149
+ tags = local.tags
150
+ }
151
+ resource "aws_lambda_event_source_mapping" "ingestion" {
152
+ event_source_arn = aws_sqs_queue.ingestion.arn
153
+ function_name = aws_lambda_function.indexer.arn
154
+ batch_size = 10
155
+ function_response_types = ["ReportBatchItemFailures"]
156
+ depends_on = [aws_iam_role_policy.indexer]
157
+ }
158
+ resource "aws_ses_domain_identity" "receiver" {
159
+ domain = var.domain
160
+ lifecycle {
161
+ precondition {
162
+ condition = data.aws_region.current.name == var.region
163
+ error_message = "The AWS provider and receiver must use the same region."
164
+ }
165
+ precondition {
166
+ condition = !(var.manage_rule_set_activation && var.existing_rule_set_name != null)
167
+ error_message = "Do not manage activation of an existing/shared receipt rule set."
168
+ }
169
+ precondition {
170
+ condition = (var.external_indexer_role_arn == null) == (var.external_reader_role_arn == null)
171
+ error_message = "Provide both external roles or neither; partial external IAM is unsafe."
172
+ }
173
+ precondition {
174
+ condition = !local.external_iam || length(var.reader_principal_arns) == 0
175
+ error_message = "External IAM mode does not create reader trust; reader_principal_arns must be empty."
176
+ }
177
+ }
178
+ }
179
+ resource "aws_ses_receipt_rule_set" "receiver" {
180
+ count = var.existing_rule_set_name == null ? 1 : 0
181
+ rule_set_name = local.rule_set_name
182
+ }
183
+ resource "aws_ses_receipt_rule" "receiver" {
184
+ name = local.rule_name
185
+ rule_set_name = local.rule_set_name
186
+ enabled = true
187
+ recipients = [var.domain]
188
+ scan_enabled = true
189
+ tls_policy = "Optional"
190
+ s3_action {
191
+ position = 1
192
+ bucket_name = aws_s3_bucket.emails.id
193
+ object_key_prefix = "incoming/"
194
+ topic_arn = aws_sns_topic.received.arn
195
+ }
196
+ depends_on = [aws_ses_receipt_rule_set.receiver, aws_s3_bucket_policy.emails, aws_sns_topic_policy.received, aws_sns_topic_subscription.ingestion, aws_lambda_event_source_mapping.ingestion]
197
+ }
198
+ resource "aws_ses_active_receipt_rule_set" "receiver" {
199
+ count = var.manage_rule_set_activation ? 1 : 0
200
+ rule_set_name = local.rule_set_name
201
+ depends_on = [aws_ses_receipt_rule.receiver]
202
+ }
203
+ resource "aws_iam_policy" "reader" {
204
+ count = local.external_iam ? 0 : 1
205
+ name = "${var.name}-reader"
206
+ policy = jsonencode({ Version = "2012-10-17", Statement = [
207
+ { Effect = "Allow", Action = ["s3:ListBucket"], Resource = aws_s3_bucket.emails.arn, Condition = { StringLike = { "s3:prefix" = ["index/*"] } } },
208
+ { Effect = "Allow", Action = ["s3:GetObject"], Resource = ["${aws_s3_bucket.emails.arn}/index/*", "${aws_s3_bucket.emails.arn}/incoming/*"] },
209
+ { Effect = "Allow", Action = ["ses:GetIdentityVerificationAttributes", "ses:DescribeActiveReceiptRuleSet"], Resource = "*" }
210
+ ] })
211
+ tags = local.tags
212
+ }
213
+ resource "aws_iam_role" "reader" {
214
+ count = !local.external_iam && length(var.reader_principal_arns) > 0 ? 1 : 0
215
+ name = "${var.name}-reader"
216
+ permissions_boundary = var.permissions_boundary_arn
217
+ assume_role_policy = jsonencode({ Version = "2012-10-17", Statement = [{
218
+ Effect = "Allow", Principal = { AWS = var.reader_principal_arns }, Action = "sts:AssumeRole"
219
+ }] })
220
+ tags = local.tags
221
+ }
222
+ resource "aws_iam_role_policy_attachment" "reader" {
223
+ count = !local.external_iam && length(var.reader_principal_arns) > 0 ? 1 : 0
224
+ role = aws_iam_role.reader[0].name
225
+ policy_arn = aws_iam_policy.reader[0].arn
226
+ }
@@ -0,0 +1,23 @@
1
+ output "hail_config" {
2
+ value = {
3
+ schemaVersion = 1
4
+ domain = var.domain
5
+ bucketName = aws_s3_bucket.emails.id
6
+ region = var.region
7
+ layout = "indexed-v1"
8
+ roleArn = local.external_iam ? var.external_reader_role_arn : length(var.reader_principal_arns) > 0 ? aws_iam_role.reader[0].arn : null
9
+ ruleSetName = local.rule_set_name
10
+ ruleName = local.rule_name
11
+ }
12
+ }
13
+ output "verification_token" { value = aws_ses_domain_identity.receiver.verification_token }
14
+ output "mx_target" { value = "inbound-smtp.${var.region}.amazonaws.com" }
15
+ output "reader_policy_arn" { value = local.external_iam ? null : aws_iam_policy.reader[0].arn }
16
+ output "ingestion_queue_url" { value = aws_sqs_queue.ingestion.url }
17
+ output "dlq_url" { value = aws_sqs_queue.dlq.url }
18
+ output "dns_records" {
19
+ value = [
20
+ { name = var.domain, type = "MX", value = "10 inbound-smtp.${var.region}.amazonaws.com", ttl = 300 },
21
+ { name = "_amazonses.${var.domain}", type = "TXT", value = aws_ses_domain_identity.receiver.verification_token, ttl = 300 }
22
+ ]
23
+ }
@@ -0,0 +1,67 @@
1
+ variable "name" {
2
+ type = string
3
+ default = "hail"
4
+ validation {
5
+ condition = can(regex("^[a-z][a-z0-9-]{2,31}$", var.name))
6
+ error_message = "Use a lowercase resource name of 3-32 characters."
7
+ }
8
+ }
9
+ variable "domain" {
10
+ type = string
11
+ validation {
12
+ condition = can(regex("^[a-z0-9][a-z0-9.-]+[.][a-z]{2,63}$", var.domain)) && length(var.domain) <= 253
13
+ error_message = "Use a dedicated lowercase test-email subdomain."
14
+ }
15
+ }
16
+ variable "region" {
17
+ type = string
18
+ default = "us-east-1"
19
+ validation {
20
+ condition = contains(["us-east-1", "us-east-2", "us-west-2", "ca-central-1", "eu-west-1"], var.region)
21
+ error_message = "This initial module supports us-east-1, us-east-2, us-west-2, ca-central-1, and eu-west-1. Validate receiving support before adding another region."
22
+ }
23
+ }
24
+ variable "bucket_name" {
25
+ type = string
26
+ default = null
27
+ }
28
+ variable "existing_rule_set_name" {
29
+ type = string
30
+ default = null
31
+ }
32
+ variable "manage_rule_set_activation" {
33
+ type = bool
34
+ default = false
35
+ description = "Explicitly opt in to changing the active SES rule set. Never enable for an existing/shared rule set."
36
+ }
37
+ variable "retention_days" {
38
+ type = number
39
+ default = 3
40
+ validation {
41
+ condition = var.retention_days >= 1 && var.retention_days <= 30 && floor(var.retention_days) == var.retention_days
42
+ error_message = "Retention must be a whole number from 1 to 30 days."
43
+ }
44
+ }
45
+ variable "reader_principal_arns" {
46
+ type = list(string)
47
+ default = []
48
+ }
49
+ variable "external_indexer_role_arn" {
50
+ type = string
51
+ default = null
52
+ description = "Precreated Lambda execution role. Set with external_reader_role_arn to suppress module IAM creation."
53
+ }
54
+ variable "external_reader_role_arn" {
55
+ type = string
56
+ default = null
57
+ description = "Precreated reader role. Set with external_indexer_role_arn to suppress module IAM creation."
58
+ }
59
+ variable "permissions_boundary_arn" {
60
+ type = string
61
+ default = null
62
+ description = "Permissions boundary for module-created IAM roles."
63
+ }
64
+ variable "tags" {
65
+ type = map(string)
66
+ default = {}
67
+ }