feat(pay-08): write verified webhook payloads to S3 audit log (post-verify, pre-parse) - #10
Merged
Merged
Conversation
…erify, pre-parse)
Every signature-verified webhook payload is now persisted to the
Object-Lock GOVERNANCE+7y audit-log bucket immediately after HMAC
verification and BEFORE the JSON envelope is parsed.
Insertion point (WebhookEventProcessor.process):
signatureVerifier.verify(...) // existing
auditWriter.recordVerifiedPayload(...) // new — post-verify, pre-parse
objectMapper.readValue(...) // existing
Three layered properties anchor the insertion point:
1. Trusted bytes only. Pre-verify writes would let an unauthenticated
attacker plant entries in an Object-Lock bucket without the signing
secret.
2. Forensics-complete. Even payloads that subsequently fail Serde
parse get audited — if our parser regresses, raw bytes are
recoverable from S3.
3. No event-id dependency. The object key is parser-independent
(the parser may fail); we use API Gateway's request id instead.
Object key shape:
YYYY/MM/DD/HH/<epochMillis>-<apiGatewayRequestId>.json
Object metadata:
stripe-signature — verbatim Stripe-Signature header (verified)
received-at — receiver clock at audit time (ISO-8601)
request-id — API Gateway HTTP request id for log correlation
SSE is intentionally NOT set in the request — the bucket-level default
(aws:kms with the application CMK) is the source of truth (IAM-2:
don't restate AWS defaults in code).
Fail-soft policy:
An S3 write failure is logged at ERROR but does NOT propagate.
Rethrowing would 5xx the webhook and force Stripe to retry indefinitely
while the bucket is broken. The loud signal is the bucket-side
CloudWatch alarm `webhook-payload-audit-put-failure` provisioned in
IaC (aws-webhook-payload-audit-s3.tofu).
OBS-3 axis 1:
The new writer is annotated with @Newspan so the trace surfaces
`webhookPayloadAuditWriter.recordVerifiedPayload` as a child span
of the existing `process` span — the post-verify / pre-parse
insertion point is observable at deploy time.
ADR-0008 boundary preserved:
The audit writer takes raw bytes only; it never receives a typed
StripeEvent. The audit layer is pre-parse and parser-independent.
Files added:
audit/S3ClientFactory.java
@factory + @Replaces(S3Client.class) + URL-connection HTTP client,
@requires(notEnv = "test"). Mirrors SesV2/DynamoDb/SecretsManager
factory pattern.
audit/WebhookPayloadAuditProperties.java
@ConfigurationProperties("webhook-payload-audit") + @PostConstruct
validation. Fail-closed at cold start if the bucket name is blank
(an audit requirement does not run in degraded mode).
audit/WebhookPayloadAuditWriter.java
@singleton @slf4j with @Newspan on recordVerifiedPayload(...).
Builds the object key + metadata, catches S3Exception and
SdkException without rethrowing, logs at DEBUG on success (NEVER
body content — identical Tomás-veto to the signature-failure log).
Files modified:
WebhookEventProcessor.java — inject the audit writer, hoist
signatureHeader out of the try-block so it's in scope for the
audit call, invoke recordVerifiedPayload immediately after the
signature verify block returns control to the happy path.
Use input.getRequestContext().getRequestId() (no Lambda Context
plumbing needed; API Gateway request id is the canonical "this
HTTP request" identifier for forensic correlation with the API
Gateway access log).
application.yml — bind webhook-payload-audit.bucket-name to the
WEBHOOK_PAYLOAD_AUDIT_BUCKET env var (no default; fail-closed).
build.gradle — add software.amazon.awssdk:s3 dependency with the
apache-client + netty-nio-client excludes (URL-connection client
is consistent with the rest of this Lambda's AWS SDKs).
Tests added:
WebhookPayloadAuditWriterTest (3 tests):
- happy path: bucket name, key shape, content-type, metadata
- S3Exception fail-soft: no rethrow
- null Stripe-Signature header: no NPE, metadata coerced to ""
WebhookEventProcessorTest (3 new tests, was 14 → now 17):
- InOrder: audit before idempotency before dispatch on success
- Audit still happens when JSON parse subsequently fails
- Audit happens before idempotency check even on replay
Plus extended invalidSignatureReturns400 to assert the audit
writer is NOT called on the pre-verify rejection path.
SecretsManagerClientFactoryReplacesTest — provide
webhook-payload-audit.bucket-name placeholder so the
function+lambda env context boots cleanly under the new
fail-closed validation.
DoD:
- New files compile
- WebhookEventProcessor.process calls auditWriter.recordVerifiedPayload
immediately after signature verify, before JSON parse
- ./gradlew test green (76 tests, 0 failures, 0 errors)
- Single commit on feat/pay-08-lambda-audit-log-writer
Out of scope: read-side IR access, replay tooling, IaC changes (sub-MR A
in parallel), DEV image release + drilling (Tomás handles after review),
ADR (anchored in IaC header comment + brief).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
miguelaferreira
approved these changes
May 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Closes PAY-08b on the Lambda code side. Companion infrastructure MR: https://gitlab.com/functorful/projects/sobrado/infrastructure/-/merge_requests/47 (env var + IR role + bucket-policy GetObject DiD + forensics runbook).
PAY-08 provisioned the Object-Lock GOVERNANCE+7y
sobrado-{env}-webhook-payload-audit-logbucket. Lambda's IAM grant for PutObject already exists. This PR is the actual Lambda-side write — every signature-verified webhook payload now persists to the audit-log bucket immediately after HMAC verification and BEFORE the JSON envelope is parsed.What changes
WebhookPayloadAuditWriter.javaS3ClientFactory.javaWebhookPayloadAuditProperties.java@ConfigurationProperties("webhook-payload-audit")with@PostConstructfail-closed on blankbucket-nameWebhookEventProcessor.javaauditWriter.recordVerifiedPayload(...)called immediately aftersignatureVerifier.verify(...)and beforeobjectMapper.readValue(...)application.ymlwebhook-payload-audit.bucket-name: ${WEBHOOK_PAYLOAD_AUDIT_BUCKET}build.gradle+1dependency:software.amazon.awssdk:s3(URL-connection only, Netty/Apache excluded)WebhookEventProcessorTest.java,WebhookPayloadAuditWriterTest.java,SecretsManagerClientFactoryReplacesTest.java,application-test.ymlTest results: 76/76 passing (10 test classes, 0 failures, 0 errors, 0 skipped).
Insertion-point invariant (load-bearing — do not move)
The audit-log write happens after HMAC verification and before JSON parse / idempotency check / dispatch. Three properties anchor this insertion point:
epochMillis + apiGatewayRequestId(noteventId). The parser may fail; the key must be derivable from data we already have.InOrder tests in
WebhookEventProcessorTestassert this structurally:auditWriterCalledExactlyOnceBeforeJsonParseOnSignatureSuccess— proves audit-before-parse-and-dispatch.auditWriterCalledBeforeJsonParseEvenWhenParseSubsequentlyFails— proves forensics-complete on malformed-but-HMAC-valid payloads.auditWriterCalledBeforeIdempotencyCheckEvenOnReplay— proves replays are audited.invalidSignatureReturns400WithGenericMessageAndDoesNotTouchDdb— proves the audit-write does NOT happen on signature failure (no plant-via-audit attack).Object key shape and metadata
Key:
YYYY/MM/DD/HH/<epochMillis>-<apiGatewayRequestId>.json(UTC components of the receiver clock).Metadata:
stripe-signature— verbatim Stripe-Signature headerreceived-at— ISO-8601 UTC instantrequest-id— API Gateway HTTP request id (joinable to the API Gateway access log)Notion AC deviations (called out explicitly per team-lead's review)
Two scope-touching deviations from the Notion AC. Both justified; documenting for paper trail. Notion AC will be updated post-merge to match implemented reality.
Deviation 1 — Key shape
{env}/stripe/{year}/{month}/{day}/{eventId}.jsonl{Y}/{M}/{D}/{H}/{epochMillis}-{requestId}.jsonJustification:
{eventId}is parser-dependent. The audit write happens pre-parse —eventIdis not yet extracted from the JSON envelope. Using a parser-dependent identifier would violate the "forensics-complete" property (a parser regression would lose the key as well as the record).{epochMillis}-{requestId}is parser-independent.request-idis also stored in object metadata and joins to API Gateway access log on the same value — forensic correlation across the audit object, CloudWatch logs, and the API Gateway access log is a literal id-equals match.{env}/stripe/prefix dropped — the bucket name already includes the env (sobrado-dev-webhook-payload-audit-log), and a futurestripe/second-tier prefix would only matter if this bucket ever held non-Stripe payloads (which it shouldn't — the bucket-policy Allow gates PutObject to the stripe-webhook role only)..jsonl→.json. Each object is a single JSON document (one webhook payload), not multi-line JSON Lines.Deviation 2 — Stripe-Signature metadata storage
Justification:
stripe-secret-rotation.md, old secret versions ARE retained in Secrets Manager.Security domain call (no Sofia escalation): verbatim is the right trade-off for the security/forensics objective. The header is not a credential and storing it adds forensic capability without expanding the data-handling obligation (it's derivative data we already had transient access to).
Fail-soft and fail-closed posture
WebhookPayloadAuditWriter.recordVerifiedPayload): bothS3ExceptionandSdkExceptionare caught, logged at ERROR (with object key + error class name only — NO body content), and NOT propagated. Rethrowing would 5xx the webhook and force Stripe into its retry loop while the bucket is broken (which would not unstick the audit gap either). The loud signal comes from the bucket-side CloudWatch alarmwebhook-payload-audit-put-failureprovisioned in IaC.WebhookPayloadAuditProperties.validate): ifWEBHOOK_PAYLOAD_AUDIT_BUCKETenv var is blank, the@PostConstructhook throwsIllegalStateException. Micronaut surfaces this as a bean construction failure at Lambda init, which AWS turns into aRuntime.InitErroron every subsequent invocation. An audit requirement does not run in degraded mode.Tomás security checklist
Tomás-veto-discipline:log.errorandlog.debuglines carry only object key + error class. NEVER body content. Verified line-by-line inWebhookPayloadAuditWriterlines 147-148, 156-157, 162-163 and inWebhookEventProcessorlines 106-113, 130-133.software.amazon.awssdk:s3(already pinned to project's AWS SDK BOM via existing config).s3ExceptionDoesNotPropagateFailSoftPolicy).SecretsManagerClientFactoryReplacesTestregression — boot must surface missing config as init error).aws:kmswith application CMK applies (IAM-2: IaC owns the encryption contract; application code does not restate it).@NewSpanannotation surfaces the operation as a child span of the processor'sprocessspan in the DD trace — observable at deploy time.Approvers (M-gate DiD)
PR author (push identity
ff-team-sobrado) cannot self-approve. Required approval shape perfeedback_claude_bot_identity_reachability:I am the security-domain reviewer and have signed off on the implementation (this PR description's review section is my line-by-line read). A non-committer approver is required to satisfy M-gate DiD.
Ordering of cutover (after merge)
v0.0.10→v0.0.11).var.stripe_webhook_image_tagininfrastructure→ apply DEV.s3://sobrado-dev-webhook-payload-audit-log/YYYY/MM/DD/HH/...→ assume IR role → GetObject → re-verify offline.Test plan
🤖 Generated with Claude Code