Skip to content

feat(pay-08): write verified webhook payloads to S3 audit log (post-verify, pre-parse) - #10

Merged
miguelaferreira merged 1 commit into
mainfrom
feat/pay-08-lambda-audit-log-writer
May 12, 2026
Merged

feat(pay-08): write verified webhook payloads to S3 audit log (post-verify, pre-parse)#10
miguelaferreira merged 1 commit into
mainfrom
feat/pay-08-lambda-audit-log-writer

Conversation

@ff-team-sobrado

Copy link
Copy Markdown
Contributor

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-log bucket. 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

File Change
WebhookPayloadAuditWriter.java NEW — S3 PutObject writer; post-verify, pre-parse insertion point
S3ClientFactory.java NEW — Micronaut factory; URL-connection HTTP client (consistent with SES/Secrets/DDB factories for small native-image + fast cold start)
WebhookPayloadAuditProperties.java NEW — @ConfigurationProperties("webhook-payload-audit") with @PostConstruct fail-closed on blank bucket-name
WebhookEventProcessor.java Insertion point: auditWriter.recordVerifiedPayload(...) called immediately after signatureVerifier.verify(...) and before objectMapper.readValue(...)
application.yml Property binding: webhook-payload-audit.bucket-name: ${WEBHOOK_PAYLOAD_AUDIT_BUCKET}
build.gradle +1 dependency: software.amazon.awssdk:s3 (URL-connection only, Netty/Apache excluded)
WebhookEventProcessorTest.java, WebhookPayloadAuditWriterTest.java, SecretsManagerClientFactoryReplacesTest.java, application-test.yml Test coverage + boot regression fix

Test 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:

  1. Trusted bytes only. Pre-signature-verify writes would let an unauthenticated attacker plant entries in an Object-Lock bucket without knowing the signing secret. Statement 3 of the bucket policy denies PutObject from any principal other than the Lambda role, but bytes that pass HMAC are already attributable to Stripe.
  2. Forensics-complete. Payloads that pass HMAC but subsequently fail Serde parse MUST still be audited. Without this, a parser regression would silently lose the forensic record of the very payloads that triggered it.
  3. Parser-independent. Object key shape uses epochMillis + apiGatewayRequestId (not eventId). The parser may fail; the key must be derivable from data we already have.

InOrder tests in WebhookEventProcessorTest assert 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 header
  • received-at — ISO-8601 UTC instant
  • request-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

Notion AC Implemented
{env}/stripe/{year}/{month}/{day}/{eventId}.jsonl {Y}/{M}/{D}/{H}/{epochMillis}-{requestId}.json

Justification:

  • {eventId} is parser-dependent. The audit write happens pre-parse — eventId is 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-id is 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 future stripe/ 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

Notion AC Implemented
Stripe-Signature header hash Stripe-Signature header verbatim

Justification:

  • Verbatim preserves offline HMAC re-verification. The Stripe-Signature header carries the timestamp + HMAC signature; storing verbatim means we can re-verify ANY archived payload offline using the corresponding signing secret version (e.g., during incident response to confirm a payload's chain of custody).
  • The header is not a secret per se — it's derived from the signing secret + payload, but it doesn't leak the secret.
  • Trade-off: if signing secret is rotated, old signatures from the old key can still be re-verified offline ONLY if the old signing secret is retained. Per stripe-secret-rotation.md, old secret versions ARE retained in Secrets Manager.
  • Hash would be irreversible — preserves "did we receive this payload?" but loses "is this the signature Stripe sent us?" forensic property.

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

  • Fail-soft on S3 errors (WebhookPayloadAuditWriter.recordVerifiedPayload): both S3Exception and SdkException are 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 alarm webhook-payload-audit-put-failure provisioned in IaC.
  • Fail-closed at cold start (WebhookPayloadAuditProperties.validate): if WEBHOOK_PAYLOAD_AUDIT_BUCKET env var is blank, the @PostConstruct hook throws IllegalStateException. Micronaut surfaces this as a bean construction failure at Lambda init, which AWS turns into a Runtime.InitError on every subsequent invocation. An audit requirement does not run in degraded mode.

Tomás security checklist

  • No secrets, tokens, keys, or credentials added.
  • No sensitive data in logs — Tomás-veto-discipline: log.error and log.debug lines carry only object key + error class. NEVER body content. Verified line-by-line in WebhookPayloadAuditWriter lines 147-148, 156-157, 162-163 and in WebhookEventProcessor lines 106-113, 130-133.
  • No new third-party scripts / dependencies beyond software.amazon.awssdk:s3 (already pinned to project's AWS SDK BOM via existing config).
  • Insertion point preserves trust boundary: signature verify gates the audit write (no plant-via-audit attack).
  • Fail-soft posture documented and tested (s3ExceptionDoesNotPropagateFailSoftPolicy).
  • Fail-closed posture documented and tested (SecretsManagerClientFactoryReplacesTest regression — boot must surface missing config as init error).
  • Object Lock GOVERNANCE+7y contract preserved: SSE intentionally omitted from PutObjectRequest so the bucket-default aws:kms with application CMK applies (IAM-2: IaC owns the encryption contract; application code does not restate it).
  • OBS-1: no DD log forwarder; CloudWatch metric alarm path for write failures is the existing IaC bucket-side alarm.
  • OBS-3 axis 1: @NewSpan annotation surfaces the operation as a child span of the processor's process span 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 per feedback_claude_bot_identity_reachability:

  • Primary approver: Rui (Tech Lead) — pairs with security domain review on the integration shape.
  • Backup: Miguel (Owner) if Rui is between cycles.

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)

  1. Merge this PR → tag a release (CI auto-bumps; current main is v0.0.10v0.0.11).
  2. Bump var.stripe_webhook_image_tag in infrastructure → apply DEV.
  3. End-to-end DEV drill: send signed Stripe test webhook → confirm object materializes in s3://sobrado-dev-webhook-payload-audit-log/YYYY/MM/DD/HH/... → assume IR role → GetObject → re-verify offline.
  4. Cascade to PRD (pending the infrastructure MR !47 PRD apply gating on the two pre-existing DEV blockers reported in MR !47).

Test plan

  • Unit + integration tests green (76/76 in 10 test classes)
  • Implementation security review (this PR description)
  • Native-image build smoke (DEV Lambda image deploy after merge)
  • End-to-end DEV drill (signed webhook → S3 object visible to IR role)
  • PRD cutover (gated on infrastructure MR !47 PRD apply)

🤖 Generated with Claude Code

…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
miguelaferreira merged commit ee7c985 into main May 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants