feat(feedback): record source IP + user agent and mirror to the audit log (BUNYIP-411) #411

Merged
longjacksonle merged 1 commit from feat/BUNYIP-411-feedback-request-metadata into main 2026-07-30 02:31:14 +02:00

What

Closes BUNYIP-411. Records the source IP, browser User-Agent, and other identifying context for every feedback submission, persists them on the record, mirrors them to the audit log, and shows them in the admin feedback detail view so spam can be traced and blocked.

How

  • The forwarding path already existed: bunyip-web resolves the end-user IP through the trusted-proxy chain and forwards it as X-Forwarded-For, plus the browser User-Agent, on every outbound /v1 call (BUNYIP-311 / BUNYIP-409), and bunyip-api's extract_client_ip resolves the external IP from those headers. This change reads the User-Agent in submit_feedback and threads both values through the create + audit paths.
  • New migration 20260730000010_feedback_request_metadata.sql adds nullable feedback.submitter_ip (INET, matching the audit actor_ip_address convention) and feedback.user_agent (TEXT). submitter_ip is the resolved external client IP, not the Docker-internal peer.
  • CreateFeedback / Feedback / AdminFeedbackDetail carry the new fields; to_admin_detail exposes the IP as a bare host string (strips the /32 CIDR suffix).
  • The existing FeedbackSubmitted audit entry now sets actor_ip_address and includes ip + user_agent in its metadata JSON (the audit schema has no dedicated UA column).
  • The admin feedback detail view renders an "IP address" and "User agent" block when present.
  • The User-Agent is trimmed and capped at 256 chars (blank treated as absent); the audit metadata is a pure builder. Both are small pure functions, kept testable without a DB.

Tests

  • bounded_user_agent_trims_blanks_and_caps_length
  • audit_metadata_carries_ip_and_user_agent / audit_metadata_omits_absent_ip_and_user_agent
  • metadata_captures_external_ip_behind_trusted_proxy (records the external IP, not the internal peer)
  • The trusted-proxy resolution itself is already covered by client_ip_uses_forwarded_for_when_peer_is_trusted_proxy (bunyip-domain) and resolve_forwarded_ip tests (bunyip-web).

just check-container is green (fmt + clippy -D warnings + build + full workspace tests). Feedback uses runtime sqlx queries, so no .sqlx regen.

Verification (live stack)

  • Migration applies: submitter_ip inet, user_agent text present on feedback.
  • A submission with X-Forwarded-For: 203.0.113.55 + a UA persists both on the row (submitter_ip=203.0.113.55 - the external IP, not the internal peer).
  • audit_logs gets a feedback_submitted row with actor_ip_address=203.0.113.55 and metadata carrying ip + user_agent.
  • The admin detail page (HTTP 200) renders the IP address and User agent. Rows predating the migration show NULL for both (back-compat holds).

🤖 Generated with Claude Code

https://claude.ai/code/session_01LerAorB26HGHLfjC9iDUbd

## What Closes BUNYIP-411. Records the source IP, browser User-Agent, and other identifying context for every feedback submission, persists them on the record, mirrors them to the audit log, and shows them in the admin feedback detail view so spam can be traced and blocked. ## How - The forwarding path already existed: bunyip-web resolves the end-user IP through the trusted-proxy chain and forwards it as `X-Forwarded-For`, plus the browser `User-Agent`, on every outbound `/v1` call (BUNYIP-311 / BUNYIP-409), and bunyip-api's `extract_client_ip` resolves the external IP from those headers. This change reads the `User-Agent` in `submit_feedback` and threads both values through the create + audit paths. - New migration `20260730000010_feedback_request_metadata.sql` adds nullable `feedback.submitter_ip` (`INET`, matching the audit `actor_ip_address` convention) and `feedback.user_agent` (`TEXT`). `submitter_ip` is the resolved external client IP, not the Docker-internal peer. - `CreateFeedback` / `Feedback` / `AdminFeedbackDetail` carry the new fields; `to_admin_detail` exposes the IP as a bare host string (strips the `/32` CIDR suffix). - The existing `FeedbackSubmitted` audit entry now sets `actor_ip_address` and includes `ip` + `user_agent` in its metadata JSON (the audit schema has no dedicated UA column). - The admin feedback detail view renders an "IP address" and "User agent" block when present. - The User-Agent is trimmed and capped at 256 chars (blank treated as absent); the audit metadata is a pure builder. Both are small pure functions, kept testable without a DB. ## Tests - `bounded_user_agent_trims_blanks_and_caps_length` - `audit_metadata_carries_ip_and_user_agent` / `audit_metadata_omits_absent_ip_and_user_agent` - `metadata_captures_external_ip_behind_trusted_proxy` (records the external IP, not the internal peer) - The trusted-proxy resolution itself is already covered by `client_ip_uses_forwarded_for_when_peer_is_trusted_proxy` (bunyip-domain) and `resolve_forwarded_ip` tests (bunyip-web). `just check-container` is green (fmt + clippy -D warnings + build + full workspace tests). Feedback uses runtime sqlx queries, so no `.sqlx` regen. ## Verification (live stack) - Migration applies: `submitter_ip inet`, `user_agent text` present on `feedback`. - A submission with `X-Forwarded-For: 203.0.113.55` + a UA persists both on the row (`submitter_ip=203.0.113.55` - the external IP, not the internal peer). - `audit_logs` gets a `feedback_submitted` row with `actor_ip_address=203.0.113.55` and metadata carrying `ip` + `user_agent`. - The admin detail page (HTTP 200) renders the IP address and User agent. Rows predating the migration show NULL for both (back-compat holds). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LerAorB26HGHLfjC9iDUbd
feat(feedback): record source IP + user agent and mirror to the audit log (BUNYIP-411)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 1m42s
Check / fmt + clippy + build + tests (pull_request) Successful in 8m24s
Create release / Create release from merged PR (pull_request) Has been skipped
b97810274d
Feedback needs no authenticated user, so request metadata is the only signal for tracing and blocking spam. Each submission now captures the external client IP and the browser User-Agent, persists them on the feedback row, mirrors them into the audit log, and surfaces them in the admin detail view.

The forwarding infrastructure already existed: bunyip-web resolves the end-user IP through the trusted-proxy chain and forwards it as X-Forwarded-For, and forwards the browser User-Agent, on every outbound /v1 call (BUNYIP-311 / BUNYIP-409). bunyip-api's extract_client_ip already resolves the external IP from those headers. This change reads the User-Agent in submit_feedback, threads both values into the create path, and enriches the FeedbackSubmitted audit entry that was already being written.

New migration adds nullable feedback.submitter_ip (INET, matching the audit actor_ip_address convention) and feedback.user_agent (TEXT). submitter_ip is the resolved external client IP, not the Docker-internal peer; both are nullable so dev / direct-hit submissions and rows predating the migration stay valid. The admin feedback detail view (feedback_detail_view) shows an IP address and User agent block when present. The audit entry sets actor_ip_address and carries ip + user_agent in its metadata JSON (the audit schema has no dedicated UA column).

The User-Agent is trimmed and capped at 256 chars (blank treated as absent) via a small pure helper, and the audit metadata is built by a pure function, both unit-tested. Tests cover UA bounding, that the audit metadata carries / omits ip + user_agent, and that the external (proxy-resolved) IP is what gets recorded, not the internal peer (the trusted-proxy resolution itself is already covered in bunyip-domain and bunyip-web).

Verified end-to-end on a live stack: the migration applies, a submission with a forwarded IP + UA persists both on the row, writes an audit_logs entry with actor_ip_address + metadata, and the admin detail page renders both values. Feedback uses runtime sqlx queries, so no .sqlx regen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LerAorB26HGHLfjC9iDUbd
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-30 02:24:28 +02:00
longjacksonle deleted branch feat/BUNYIP-411-feedback-request-metadata 2026-07-30 02:31:14 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!411
No description provided.