fix(cdk): explicit Lambda LogGroups replace log_retention (INFRA-114) (#126)
Some checks are pending
Deploy / deploy (push) Waiting to run

First seahaven-prod deploy failed CREATE on the sender-auth MetricFilter:
it imported /aws/lambda/<fn> by name, which pre-existed in mgmt but not in
a fresh account. All 5 functions now get an explicit logs.LogGroup
(TWO_MONTHS, RETAIN) via common.make_function_log_group, and the metric
filter takes the construct so CFN orders it after the group exists.

Also removes the deprecated LogRetention custom resource and its
wildcard logs:PutRetentionPolicy role (CKV_AWS_111). Function roles keep
AWSLambdaBasicExecutionRole (verified in the synthesized template), so
log-write permissions are unchanged; cross-review's grant_write FIX was
a false positive on that basis. Supersedes PR #84, which hardcoded the
mgmt logs-CMK ARN and predates the common.py refactor.

mgmt collision note: these CREATEs would collide with the pre-existing
groups in mgmt; acceptable because the deploy secret now targets prod
and mgmt is frozen pending decommission.
This commit is contained in:
Adam Moussa 2026-07-23 17:21:07 -04:00 • committed by GitHub
parent 073201f633
commit 00d0d32337
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 65 additions and 20 deletions

View file

@ -73,7 +73,36 @@ def add_ddb_alarms(scope, id_prefix, table, alarm_name_prefix, alarm_topic):
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
def add_sender_auth_rejected_alarm(scope, id_prefix, function_name, alarm_topic):
def make_function_log_group(scope, id_prefix, function_name):
"""Explicit log group for a Lambda, replacing the deprecated
``log_retention`` prop (INFRA-114).
Making the group a real stack resource (a) drops the LogRetention custom
resource whose role carried wildcard ``logs:PutRetentionPolicy`` (checkov
CKV_AWS_111), and (b) makes the group an orderable CFN dependency:
consumers like the sender-auth metric filter now deploy AFTER the group
exists. The previous by-name import raced group creation in a fresh
account and failed the first seahaven-prod deploy (the mgmt account
masked this because its groups predated the filter).
RETAIN matches the repo convention for stateful resources and mirrors the
old behavior (LogRetention never deleted groups on stack delete). NOTE:
in an account where ``/aws/lambda/<fn>`` already exists out-of-band
(mgmt), deploying this CREATE would collide -- acceptable because mgmt is
frozen post-migration and never redeployed from main.
"""
return logs.LogGroup(
scope,
f"{id_prefix}LogGroup",
log_group_name=f"/aws/lambda/{function_name}",
retention=logs.RetentionDays.TWO_MONTHS,
removal_policy=RemovalPolicy.RETAIN,
)
def add_sender_auth_rejected_alarm(
scope, id_prefix, function_name, alarm_topic, log_group
):
"""Metric-filter + alarm on ``sender_auth_rejected`` warnings (INFRA-107).
A rejected inbound email is skipped without erroring the invocation, so it
@ -84,21 +113,18 @@ def add_sender_auth_rejected_alarm(scope, id_prefix, function_name, alarm_topic)
mail while the pipeline reports healthy.
ALARM-only SnsAction to site-alerts; no OK action. The metric filter reads
the function's own log group (imported by the deterministic
``/aws/lambda/<fn>`` name, created by the function's log_retention). A plain
substring pattern is used because Lambda prefixes each line with its own
level/timestamp/request-id, so the JSON payload is not a standalone JSON
log event a `{$.event=...}` pattern could match.
the function's own log group, passed in as the EXPLICIT LogGroup construct
(from ``make_function_log_group``) so CFN orders the filter after the
group exists -- a by-name import here failed the first fresh-account
deploy. A plain substring pattern is used because Lambda prefixes each
line with its own level/timestamp/request-id, so the JSON payload is not
a standalone JSON log event a `{$.event=...}` pattern could match.
"""
metric_name = f"{function_name}-sender-auth-rejected"
logs.MetricFilter(
scope,
f"{id_prefix}SenderAuthRejectedFilter",
log_group=logs.LogGroup.from_log_group_name(
scope,
f"{id_prefix}LogGroup",
f"/aws/lambda/{function_name}",
),
log_group=log_group,
filter_pattern=logs.FilterPattern.literal('"sender_auth_rejected"'),
metric_namespace=_SENDER_AUTH_METRIC_NAMESPACE,
metric_name=metric_name,

View file

@ -11,7 +11,6 @@ from aws_cdk import (
aws_kms as kms,
aws_lambda as lambda_,
aws_lambda_event_sources as lambda_event_sources,
aws_logs as logs,
aws_s3 as s3,
aws_s3_notifications as s3n,
aws_ses as ses,
@ -91,6 +90,9 @@ class PoIngestStack(Stack):
email_processor_dlq = common.make_processor_dlq(self, "EmailProcessorDlq")
# --- Lambda function ---
email_processor_log_group = common.make_function_log_group(
self, "EmailProcessor", "po-email-processor"
)
email_processor = lambda_.Function(
self,
"EmailProcessor",
@ -134,7 +136,7 @@ class PoIngestStack(Stack):
),
timeout=Duration.seconds(60),
memory_size=256,
log_retention=logs.RetentionDays.TWO_MONTHS,
log_group=email_processor_log_group,
dead_letter_queue=email_processor_dlq,
environment={
"PO_TABLE": "purchase-orders",
@ -196,7 +198,11 @@ class PoIngestStack(Stack):
# false-reject storm pages instead of vanishing. default_value=0 keeps the
# series populated (alarm stays OK, never INSUFFICIENT_DATA) between events.
common.add_sender_auth_rejected_alarm(
self, "EmailProcessor", "po-email-processor", alarm_topic
self,
"EmailProcessor",
"po-email-processor",
alarm_topic,
email_processor_log_group,
)
# --- Template fallback-rate alarm: po-email-processor ---
@ -372,6 +378,7 @@ class PoIngestStack(Stack):
)
# --- Web UI Lambda ---
web_ui_log_group = common.make_function_log_group(self, "WebUI", "po-web-ui")
web_ui = lambda_.Function(
self,
"WebUI",
@ -409,7 +416,7 @@ class PoIngestStack(Stack):
),
timeout=Duration.seconds(60),
memory_size=256,
log_retention=logs.RetentionDays.TWO_MONTHS,
log_group=web_ui_log_group,
environment={
"PO_TABLE": "purchase-orders",
# Defense-in-depth shared secret for the web UI handler. The
@ -464,6 +471,9 @@ class PoIngestStack(Stack):
# 518 WCU of write amplification. Re-add if a state-level query path ships.
# --- Site extractor Lambda (DynamoDB Streams → verified-sites) ---
site_extractor_log_group = common.make_function_log_group(
self, "SiteExtractor", "po-ingest-site-extractor"
)
site_extractor = lambda_.Function(
self,
"SiteExtractor",
@ -483,7 +493,7 @@ class PoIngestStack(Stack):
),
timeout=Duration.seconds(60),
memory_size=256,
log_retention=logs.RetentionDays.TWO_MONTHS,
log_group=site_extractor_log_group,
environment={
"VERIFIED_SITES_TABLE": verified_sites_table.table_name,
"PENDING_REVIEW_TABLE": "pending-site-review",

View file

@ -9,7 +9,6 @@ from aws_cdk import (
aws_cloudwatch_actions as cw_actions,
aws_dynamodb as dynamodb,
aws_lambda as lambda_,
aws_logs as logs,
aws_s3 as s3,
aws_s3_notifications as s3n,
aws_ses as ses,
@ -89,6 +88,9 @@ class WorkorderIngestStack(Stack):
email_processor_dlq = common.make_processor_dlq(self, "EmailProcessorDlq")
# --- Lambda function ---
email_processor_log_group = common.make_function_log_group(
self, "EmailProcessor", "workorder-email-processor"
)
email_processor = lambda_.Function(
self,
"EmailProcessor",
@ -119,7 +121,7 @@ class WorkorderIngestStack(Stack):
),
timeout=Duration.seconds(60),
memory_size=256,
log_retention=logs.RetentionDays.TWO_MONTHS,
log_group=email_processor_log_group,
dead_letter_queue=email_processor_dlq,
environment={
"WORK_ORDERS_TABLE": work_orders_table.table_name,
@ -194,7 +196,11 @@ class WorkorderIngestStack(Stack):
# dropped. This metric filter + alarm turns those warnings into a paging
# signal so a false-reject storm surfaces instead of a silent outage.
common.add_sender_auth_rejected_alarm(
self, "EmailProcessor", "workorder-email-processor", alarm_topic
self,
"EmailProcessor",
"workorder-email-processor",
alarm_topic,
email_processor_log_group,
)
# --- Template fallback-rate alarm: workorder-email-processor ---
@ -304,6 +310,9 @@ class WorkorderIngestStack(Stack):
)
# --- Web UI Lambda ---
web_ui_log_group = common.make_function_log_group(
self, "WebUI", "workorder-web-ui"
)
web_ui = lambda_.Function(
self,
"WebUI",
@ -340,7 +349,7 @@ class WorkorderIngestStack(Stack):
),
timeout=Duration.seconds(15),
memory_size=128,
log_retention=logs.RetentionDays.TWO_MONTHS,
log_group=web_ui_log_group,
environment={
"WORK_ORDERS_TABLE": work_orders_table.table_name,
"COMMENTS_TABLE": comments_table.table_name,